diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index f62a878c..1c4290a7 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -513,6 +513,7 @@ session, rather than one at a time: | V4g | Paperdoll and appraisal viewports, portal transit | | V6d | The paperdoll/appraisal viewport sprite. It is the one retained-UI texture the gate's scene never draws, and V6d changed how every UI texture is sampled — from a bound texture unit to a table slot. The seam that registers it (`GlGpuDevice.RegisterExternalColorTexture`) is unchanged and its handle is now simply encoded rather than resolved back to a GL name, but that path is unproven by anything automated. Check it with the dungeon/portal pass above rather than on its own | | V6e | **Particles, again — and now sky.** The particle half is V2c's and V4e's debt restated: the offline scene draws no particles, so nothing automated saw the varying retype or the ACDREAM_TEXTURE_NONE sentinel. The sky half is new and larger: V6e moved a dozen loose uniforms into a `SkyParams` uniform buffer and moved the sky's texture from a bound unit-0 texture-plus-sampler to a bindless (texture, wrap) table slot, and the gate masks the sky band for determinism. What WAS checked, and should be read as bounding the risk rather than closing it: a base-versus-head offline capture at **all seven day groups**, matching in gradient, cloud sheet, horizon band and fog on every one — including day group 2's salmon cloud band and day group 6's green band, which exercise texture sampling, tint, blend and fog together — plus 3/3 RENDERED on the desktop-witness repeat gate. **What remains unproven is pixel-exactness and the parts of the dome the fixed outdoor camera cannot see**: the sun and moon (additive surfaces high in the sky) and the rain cylinder, which is the one sky mesh that surrounds the camera and the one whose REPEAT wrap mode is most visible. Stand outside at dawn or dusk, and stand in rain | +| V6k | **The sky band and the paperdoll — both CHECKED rather than banked, and recorded here so the next slice does not re-open them.** Commit 1 carries a seven-day-group before-and-after comparison on GL (V6e's method) because the gate masks the sky; commit 2 carries a connected run that presses `ToggleInventoryPanel` and captures the doll through the new render target (`artifacts/v6k-paperdoll`). **What remains uncovered:** the creature-appraisal viewport, which is the same class and the same code as the paperdoll but was not itself driven; and the sun, moon and rain cylinder, which V6e already filed and a fixed outdoor camera at one time of day still cannot see | | V6f | **Terrain seen through a doorway clip region.** The offline gate covers terrain heavily — blending, road overlays and the water edge are most of the frame, and every one of those samples goes through `terrainTiling()`, so the std140 stride and the new `ACDREAM_SAMPLE_ARRAY` reads are well proven. What it cannot see is the one terrain path with its own binding: the clip UBO at binding 2, exercised when terrain is viewed through a doorway. Binding 3 now sits beside it and is rebound per draw, so a bind-order mistake would show exactly there. Check it with the dungeon/doorway pass above | **The user confirmed on 2026-07-27 that the local ACE server is always available @@ -551,9 +552,9 @@ because sample positions are not specified across implementations. | **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** ✅ | **World texture stack** (added 2026-07-27, see §5.3; reported in §5.5.11). Two commits: **1** the GL device's world-handle seam plus `TerrainAtlas`/`TerrainModernRenderer` (`b8bcaa3e`); **2** `CompositeTextureArrayCache`, the particle arrays, `ObjectMeshManager`'s material path, and the retype of `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`, retiring the interim tables in `WbDrawDispatcher`, `EnvCellRenderer`, `TerrainModernRenderer` and `ParticleRenderer` (`565c351f`). **Narrowed from the original scope in one way:** the caches still create and own their GL textures and residency — the device owns only the table entry — so `ManagedGLTextureArray` and the raw `Texture2D` upload path are untouched and `IGpuTexture` creation moves with the Vulkan world arm. `SkyRenderer` keeps its own table; see §5.5.11. | pixel gate per commit (3.02e-05; 5.50e-05 and 3.91e-05 on two captures against a 19 px same-commit control), App tests, 3/3 desktop-witness connected run per commit, one validation-layer Vulkan run per commit | | **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 | +| **V4e** ⛔ **BLOCKED on a contract decision — §5.5.16** | `ParticleRenderer`. Both particle pipelines draw with per-instance VERTEX attributes, which `GpuVertexLayout` (one stride, no divisor) and `IGpuPassEncoder.BindVertexBuffer` (one buffer, vertex rate) cannot express. §5.5.16 states the three ways out; each is a §3.3 change. | pixel gate (particle-heavy checkpoint) | +| **V4f** ✅ **landed in V6k** | `SkyRenderer` + weather, as the Vulkan arm (`22aa2edc`); reported in §5.5.16. | seven-day-group GL comparison, GL pixel gate, 3-run connected, validation-clean Vulkan run | +| **V4g** ◐ **half-landed in V6k** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget` ✅ (`eb7e6b4e`), which also discharged §5.4. `PortalTunnelPresentation` needed no port — it draws into the backbuffer. **`PortalDepthMaskRenderer` is NOT ported**: its two-pass punch needs stencil state the pinned `GpuPipelineDescription` does not carry (§5.5.16). | GL pixel gate, 3-run connected, validation-clean Vulkan run, and a connected paperdoll capture | | **V4h** ↻ **re-sequenced — §5.5.5** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) | | **V5** ✅ | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 | | **V6** | Vulkan RHI backend, dark, four sequential commits: **a** ✅ allocator/buffers/staging/rings/timeline (`fb9c6693`); **b** ✅ textures/BC mips/samplers/descriptor table/render targets/MSAA resolve (`9eae4963`); **c** ✅ `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names (`234fe91d`); **d** ✅ first production renderers — `TextRenderer` and `DebugLineRenderer` on both backends, the colour-format contract amendment, and the retained UI drawn on Vulkan; **e** ✅ every remaining production shader crosses the dialect — `mesh_modern` (`935f4dc3`), both particle pairs (`602bc9dd`) and `sky` — leaving 8/9 pairs compiling to SPIR-V. **Milestone deferred:** "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers plus `TextureCache` are still raw GL, so V6 delivers the backend, the two renderers that can use it today, and the shaders the Vulkan world path will be built on. | per-commit build + tests; V6d additionally pixel-gates GL and captures a Vulkan UI frame; V6e pixel-gates GL per commit and adds a seven-day-group sky comparison plus a 3-run desktop-witness gate | @@ -577,6 +578,7 @@ tenth pair with no consumer at all; see the V6e report. | **V6i** ✅ | **The world arm's prerequisites**, in two parts. **V6i-1** (`df6e2a79`, reported in §5.5.12) closed §5.5.8's one-binding-two-buffers hazard with one descriptor-set pair per renderer scope, derived from the descriptor state rather than declared, and measured the ordered remainder list the world arm still needed. **V6i-2** (reported in §5.5.13) took items 1, 2 and 6 of that list in three gated commits: **1** the `TerrainClip` descriptor-set fix plus set 1's missing bindings 2 and 4, proven by `spirv-dis` and now gated by a SPIR-V-reading contract test (`f7344758`); **2** world texture CREATION crosses to `IGpuTexture` — `IWorldTextureArray` over `TextureAtlasManager`/`ManagedGLTextureArray`, `TerrainAtlas`'s second construction path, and `ICompositeTextureArrayBackend`'s RHI arm — with the Vulkan arm exercised at startup (`c8d0f70b`); **3** `IMeshPipelineDevice` decouples `ObjectMeshManager`/`WbMeshAdapter` from `OpenGLGraphicsDevice`. **Items 3–5 — the submission arms, `RetailPViewPassExecutor`, and the pass-structure merge — are the next slice's.** | pixel gate per commit (3.02e-05, 3.20e-05, 1.60e-05 vs `0ca802cd`), App tests 4,109/3 and complete Release suite 9,172/5, 3/3 desktop-witness connected run at commits 2 and 3, one validation-layer Vulkan run per commit | | **V6i-3** | **The mesh pipeline runs on both arms, and the Vulkan frame gets a world pass**, reported in §5.5.14. Two commits: **1** `GlobalMeshBuffer` takes `GL?` and publishes `VertexStore`/`IndexStore`, `ObjectMeshManager`'s `RequireGl` narrows to the unreachable legacy upload, `VulkanMeshPipelineDevice` is `IMeshPipelineDevice`'s second implementation, and `NullWbMeshAdapter` is deleted (`fe8abacf`); **2** the clear merges into the world pass with `Store = Resolve` and descriptor sets bind at draw time (`887de4ae`). **The world renderers' submission arms and `RetailPViewPassExecutor` did NOT land** — §5.5.14 enumerates what they still need. | pixel gate per commit (4.44e-05, 3.73e-05 vs `579e0b7f`), App tests 4,112/3, one 3/3 desktop-witness connected run at HEAD, one validation-layer Vulkan run per commit, and a bit-identical (0/921,600) Vulkan capture across the pass merge | | **V6j** ✅ | **The world arm, whole**, reported in §5.5.15. Two commits: **1** `VulkanViewportMapping` stops inverting the front face — the world arm is the mapping's first culling consumer and measured that the inversion culls terrain outright and turns every closed shell inside-out (`81fe5e1b`); **2** the three renderers' RHI submission arms as a SECOND arm per §5.5.6, both pass executors made backend-neutral behind `IWorldPassSurface`, `VulkanWorldPassScope` publishing the frame's one pass, `WorldFrameSections` carrying the three frame-global sections, and the composition that reaches them (`f84eef32`). **`ACDREAM_RENDER_BACKEND=vulkan` renders Dereth**: terrain, blending, roads, water, statics, scenery and the retained UI. Sky stays fog until V4f. | GL pixel gate 5.50e-05 vs `847f14ae` with a characterised 12–31 px noise distribution, App tests 4,112/3, complete Release suite 9,175/5, GL connected `-Runs 3` at 3/3 on both columns, one validation-layer Vulkan run at 0 errors / 0 warnings, and the world PNG inspected in §5.5.15 | +| **V6k** ✅ | **Sky, viewports, and §5.4**, reported in §5.5.16. Two commits: **1** `SkyRenderer`'s RHI arm — V4f's content, two blend pipelines, `SkyParams` as a per-draw ring slice, the first Vulkan consumer of set 1 binding 4 — plus the retirement of the last interim `GlBindlessHandleTable` (`22aa2edc`); **2** `PrivateEntityViewportRenderer` onto `IGpuRenderTarget`, the deletion of §7.1's external-texture seam, and **§5.4's obligation discharged** (`eb7e6b4e`). **Particles did not land**: the pinned contract cannot express instanced vertex input, which is what both particle pipelines are built on. | GL pixel gate 4.43e-05 then 4.08e-05 (25 and 23 px, band 9–31), App tests 4,109/3, complete Release suite 9,172/5, GL connected `-Runs 3` at 3/3 on both columns per commit, one validation-layer Vulkan run at 0 errors / 0 warnings per commit, a seven-day-group GL sky comparison, a connected paperdoll capture, and a GL-versus-Vulkan inspection in §5.5.16 | | **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 | @@ -1986,7 +1988,186 @@ deliberately does not compose. been seen by the user, and no dungeon, portal, paperdoll or spell effect has been drawn on it. The milestone "full game frame on Vulkan" needs V4e–V4g behind it. -### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6 +#### 5.5.16 V6k (2026-07-28): the sky draws, the viewports name their targets, and particles hit the contract + +Two of the three renderers §5.5.15 listed as absent are landed, §5.4's obligation +is discharged, and the third renderer stopped against the pinned contract rather +than working around it. + +**1. The sky** (`22aa2edc`). V4f's content, as a second arm per §5.5.6. +`ACDREAM_RENDER_BACKEND=vulkan` renders the dome quadrants, the horizon band, the +cloud sheet and the fog gradient in the same place and the same colours as the GL +capture of the same scene. Three differences from the GL arm, each because Vulkan +bakes what GL sets: the per-submesh blend function becomes two PIPELINES; the +`SkyParams` block becomes a ring slice taken per draw, because a descriptor's +contents are read at execution time and not at record time; and the pass is +borrowed from `IWorldPassScope`. The sky is **the first Vulkan consumer of set 1 +binding 4** — §5.5.8 recorded that `UniformSkyParams` was missing from the uniform +set layout and V6i-2 added it, but nothing had ever bound it. + +**The fourth defect of the compiles-clean class, and the first the campaign found +by capture rather than by validation.** The first Vulkan sky frame drew the dome +as a field of blue-white noise. The RHI vertex layout declared a 32-byte stride — +position, normal, texcoord, exactly what `sky.vert` reads — while +`AcDream.Core.Terrain.Vertex` is **36 bytes**: it carries a fourth member, +`TerrainLayer`, that no sky attribute names and that the GL arm never describes to +a `glVertexAttribPointer` but does count, because it says `sizeof(Vertex)`. No +validation rule was violated, nothing else in the frame looked wrong, and the +offline gate masks the sky band. `SkyVertexLayoutTests` now asserts the +requirement — the stride is the uploaded record's footprint — rather than the +number, which is the lesson §5.5.15 drew from the winding inversion applied to a +different surface. **Worth generalising: every `.Rhi.cs` arm that declares a +`GpuVertexLayout` is restating a CPU record's footprint from memory, and only one +of them has a test.** + +The slice also retired **the last interim `GlBindlessHandleTable` in the tree**. +V4t left the sky's because the sky is the one world path that mints its own +resident handles rather than interning someone else's; it now registers them +through V4t's `RegisterWorldTextureHandle` seam, and the class and its six tests +are deleted. `TextureCache.RegisterWorldSurface(surfaceId, repeat)` is the RHI +arm's texture source: the same `DecodeFromDats`, created through +`IGpuDevice.CreateTexture` and paired with a real sampler object. + +**2. The viewports, and §5.4** (`eb7e6b4e`). `PrivateEntityViewportRenderer` asks +the device for an `IGpuRenderTarget` instead of hand-rolling an FBO, colour +texture and depth renderbuffer; its pass DECLARES that target; and the colour +attachment is registered into the global table like any other texture. That +deletes the V4a pre-approved transitional seam (`RegisterExternalColorTexture` / +`TryResolveExternalColorTexture`) on the slice §7.1's final paragraph named as its +end, and makes both retained-UI frame views backend-neutral. + +**§5.4's answer is not the one that section predicted, and the distinction +matters.** The divergence itself — GL's `BeginPass` refusing to bind framebuffer 0 +for a null target — has not been on the tree since the V4c revert (`543bc79f`) +took that hunk with it. What the revert did not undo was the reason it existed: +this renderer bound a framebuffer no pass had declared. It now names its target, +and `PortalTunnelPresentation` was re-read and draws into the active viewport — +the backbuffer, which is what a null target literally means. **V7 is no longer +blocked on §5.4.** + +**The §5.5.7 re-check does not come back clean, and is now a loud precondition.** +That note asked that "the render-target-view-in-table usage from V6c did not fire" +not be carried forward as accepted. It still does not fire, and now for a reason +worth stating: a Vulkan render-target image is viewed as +`VK_IMAGE_VIEW_TYPE_2D` because that is what an ATTACHMENT needs, while the +texture table's descriptor array is declared `sampler2DArray`, so registering one +is invalid usage rather than a mismatch that samples oddly. It has never fired +because the only renderer with an offscreen target is composed on GL alone. +`VulkanGpuDevice.RegisterTexture` now refuses it and names the fix — a second, +layered sampled view per render target — so the slice that gives the Vulkan arm a +viewport meets a precondition instead of a driver-level fault. + +**3. Particles are NOT landed, and the reason is the pinned contract.** +`GpuVertexLayout` has one stride and no instance divisor, and +`IGpuPassEncoder.BindVertexBuffer` binds one buffer at `VertexInputRate.VERTEX`. +Both particle pipelines draw with per-instance vertex attributes — `particle` at +locations 2–6 (centre, two sheet axes, colour, texture slot) and `particle_mesh` +at 3–7 (a `mat4` model and a colour). **The contract can express instanced +DRAWING — `Draw`/`DrawIndexed` both take `firstInstance` — but not instanced +vertex INPUT**, which is what these two shaders are built on. Three ways out, +each a decision rather than an implementation: + +- **(i) Grow the contract**: a second vertex binding with a divisor. Smallest + change at the call sites, and the same shape as the four amendments already + taken (`InverseAlpha`, `UByte4UInt`, `ColorFormat`, the tiling binding). +- **(ii) Move particle instances to a storage buffer** indexed by + `gl_BaseInstanceARB + gl_InstanceID`, which is how the world path already + works. This converges the shaders rather than forking them, but it needs a + storage binding, and `GpuBindingModel`'s ten are all spoken for — reusing + binding 0 would have the GL particle draw clobber `WbDrawDispatcher`'s + instance array mid-frame, which is §5.5.8's hazard in its GL form. So this is + a binding-model change, i.e. also a contract change. +- **(iii) CPU-expand instances to per-vertex data on the Vulkan arm only.** + Needs no shader, contract or GL change and would change no pixel, but it + multiplies billboard vertex bandwidth about five-fold and does not scale to + mesh particles at all, where it would duplicate a whole mesh per instance. + +Option (ii) is the one the campaign's own architecture points at, and (i) is what +makes it unnecessary. Either way, **V4e stays open** and the choice belongs to +whoever owns §3.3. + +**Gates, per commit.** Release build green. App tests **4,109 / 3 skips** — the +4,112 baseline less the six `GlBindlessHandleTable` tests that went with the +class, plus three vertex-layout tests; complete Release suite **9,172 / 5**. +Strict GL offline pixel gate: **4.43e-05** (25 px) at commit 1 against +`7ae796a1` and **4.08e-05** (23 px) at commit 2 against `22aa2edc`, both inside +the documented 9–31 band with `maximumChannelDelta` 48 — the same 46–52 every +control pair reports. GL connected `-Runs 3`: **3/3 RENDERED** on the desktop +witness and 3/3 on the client capture, per commit. One offline Vulkan run with +`VK_LAYER_KHRONOS_validation` proven inserted by the loader per commit: **zero +validation errors, zero warnings**. + +**Two uncovered surfaces were checked rather than banked.** The sky band is +masked by the pixel gate, so commit 1 carries a seven-day-group before-and-after +comparison on GL — V6e's method — matching in gradient, cloud sheet, horizon band +and fog on every group, including day group 2's salmon band and day group 6's +green one. The offline scene never opens the inventory, so commit 2 carries a +connected run that presses `ToggleInventoryPanel` and captures the result +(`artifacts/v6k-paperdoll`): the doll renders through the new render target with +the correct pose, orientation and alpha. That is §5.1's "the paperdoll, then +examine an item" row, half-discharged — the appraisal viewport shares the same +class and the same code, but was not itself driven. + +**The Vulkan frame, inspected against a GL capture of the same scene.** Captured +back to back (`artifacts/v6k-vk-final` versus `artifacts/v6k-gl-final`), both at +4× MSAA with alpha-to-coverage on, the two frames differ in **8.83% of pixels +(81,359 of 921,600), mean absolute channel delta 0.82, maximum 174**. The +difference map (`artifacts/diff-gl-vk.png`) puts essentially all of it in two +populations, and neither is a defect: + +- **Foliage and silhouette edges** — 51,003 of the 81,359 differing pixels are in + the top 200 rows, which is the treeline, and the map shows the population + hugging every tree and building outline. These are alpha-to-coverage sample + positions, which are explicitly unspecified across implementations; §5.1 + already requires the V7 differential to force MSAA off for exactly this. +- **The sky band's own animation** — two launches cannot agree on the Dereth + clock, which is why the GL gate masks the same band. + +Terrain, blending, roads, the water edge and the entire retained UI are +pixel-quiet: rows 300–720 contribute 980 differing pixels in total, 0.13% of the +population. + +**The V7 defect list, updated.** + +1. ~~Sky~~ (landed), ~~viewports~~ (landed, GL arm), ~~§5.4~~ (discharged). + **Particles remain absent** on both the Vulkan arm and V4e, blocked on the + contract question above. +2. **`PortalDepthMaskRenderer` is unported and needs a contract dimension.** Its + two-pass punch (#117) is built on `glStencilFunc`/`glStencilOp`/`glStencilMask` + and `GpuPipelineDescription` has no stencil state at all. It is raw GL, it + works, and it is invisible to the Vulkan arm, so nothing is broken — but the + V4g row's "stencil/depth-mask pipelines" cannot be written against the pinned + contract as it stands. +3. **The paperdoll and appraisal viewports do not exist on the Vulkan arm**, and + giving them one is more than composition: the offscreen target is + single-sampled by contract while every world pipeline is built at the + backbuffer's sample count, so `WbDrawDispatcher` would need sample-count + pipeline variants the way §5.5.8 gave it depth-format variants. Plus the + layered-view fix in item 2 of the §5.5.7 re-check above. +4. **`EnvCellRenderer`'s Vulkan arm is still unproven by anything automated** + (unchanged from §5.5.15), as are the deferred-alpha path and the doorway + scissor. +5. **MSAA must be forced off for the differential** — now measured rather than + predicted: it is 8.8% of the frame at 4×. +6. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (unchanged; a V8 + item). + +### 5.4 The null-target `BeginPass` divergence (V4c) — ✅ DISCHARGED at V6k + +> **Closed 2026-07-28 by V6k commit 2 (`eb7e6b4e`); see §5.5.16.** The answer is +> not the one this section predicts, and both halves are worth keeping straight. +> The *divergence itself* — GL's `BeginPass` refusing to bind framebuffer 0 for a +> null target — has not been on the tree since the V4c revert (`543bc79f`) took +> that hunk with it; GL binds the declared target today and has for a while, so +> the two backends already agreed about what `Target: null` means. What the +> revert did **not** undo was the *reason* the divergence existed: +> `PrivateEntityViewportRenderer` still bound a framebuffer no pass had declared. +> V6k ported it onto `IGpuRenderTarget`, so it now names its target; +> `PortalTunnelPresentation` was re-read and draws into the active viewport +> rather than an offscreen buffer, which *is* the backbuffer and therefore +> already means exactly what a null target means. Obligation 1 below is met, +> obligation 2 is moot, and **V7's differential is no longer blocked on this**. +> The rest of the section is kept for the record. V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares `Target: null`. The reason is sound: `PrivateEntityViewportRenderer` and