From 6aee01cf722cd147f78fae2c4891322d1b769cb1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 18:08:04 +0200 Subject: [PATCH] docs(render): record V6l - three amendments, and the last three renderers cross Section 5.5.17 records the slice: the instanced-vertex-input amendment and particles (b1ad1d48), the stencil dimension and the portal mask (eced67d0), and the offscreen viewports (2e8b8b91). The V4e row is no longer blocked and the V4g row is no longer half-landed; the slice table gains a V6l row and section 5.1's accumulated-debt table gains one for the two connected captures the offline gate cannot reach. Four defects are recorded as found by RUNNING rather than by validation, which is the pattern this campaign keeps paying for: the standalone particle texture cache and the entity-appearance composite cache were both bindless-only, so the Vulkan arm could draw neither a textured particle nor any entity with a palette override; a pipeline bakes one depth/stencil format, so an offscreen target's depth had to take the device's; and the paperdoll rendered upside down because a GL framebuffer's origin is bottom-left and a Vulkan image's is not. The V7 list is rewritten. Nothing on it is blocked on a contract decision any more. What is left is one absent renderer (PortalTunnelPresentation has no Vulkan arm), EnvCellRenderer's arm narrowed from unproven to proven-by-one-frame after a Marketplace interior rendered on Vulkan, the MSAA-off requirement, the per-draw descriptor writes, the portal mask's two shader sources, and the appraisal viewport's carried-forward half-discharge. AP-92 is narrowed rather than retired: the private viewports are backend-neutral targets on both arms and the blit's V origin is derived rather than assumed, so the origin half of that row's risk column is closed. The rest of it - retail renders each CreatureMode directly against a cloned CPhysicsObj - is unchanged. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- docs/plans/2026-07-27-vulkan-campaign.md | 195 +++++++++++++++++- 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1fc3c0f5..ec9d5533 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -192,7 +192,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) | | AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 | -| AP-92 | Private creature viewports (paperdoll and examination) render through isolated FBO/RTTs and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | FBO origin, alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | +| AP-92 | Private creature viewports (paperdoll and examination) render through isolated `IGpuRenderTarget`s and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose. **V6l narrowing (2026-07-28):** the target is backend-neutral on both arms and the blit's V origin is no longer assumed — `IUiViewportRenderer.TextureIsBottomUp` derives it from the backend that made the texture, so a GL framebuffer's bottom-left origin and a Vulkan image's top-left one both composite upright. | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | Alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation. The origin half of this risk is closed on both backends; a THIRD backend would have to answer `TextureIsBottomUp` for itself | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | | AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; `RetailPaperdollPoseApplicator` in `src/AcDream.App/Rendering/PaperdollFramePresenter.cs` | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` | | AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` | | ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` | diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 1c4290a7..fee977f9 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -514,6 +514,7 @@ session, rather than one at a time: | 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 | +| V6l | **The Vulkan paperdoll and a particle effect — both CHECKED rather than banked, and both connected, because the offline gate reaches neither.** The doll capture (`artifacts/v6l-vk-paperdoll3` against `artifacts/v6l-gl-paperdoll`) is also the no-regression check for making the viewport's V orientation backend-derived. The particle capture (`artifacts/v6l-vk-poi` against `artifacts/v6l-gl-poi`) is Holtburg's forge plume and glint field, cropped 4× at `artifacts/crop-vk-glow.png` / `crop-gl-glow.png`. **What remains uncovered:** the creature-appraisal viewport, which is the same class and the same code as the paperdoll but was again not itself driven — V6k's half-discharge, carried forward unchanged; and the portal depth mask, which drew no pixel in either capture because neither run entered a building aperture. Check it with the dungeon/doorway pass above | | 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 @@ -552,9 +553,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** ⛔ **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) | +| **V4e** ✅ **landed in V6l** | `ParticleRenderer`, as the Vulkan arm (`b1ad1d48`); reported in §5.5.17. Unblocked by amendment 1 — a second vertex binding at `VK_VERTEX_INPUT_RATE_INSTANCE` / divisor 1, §5.5.16's option (i) — plus `GpuVertexFormat.UInt1` for `particle.vert`'s scalar `uint` slot, so no shader was edited. | GL pixel gate, 3-run connected, validation-clean Vulkan run, and a connected GL-versus-Vulkan particle capture | | **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 | +| **V4g** ✅ **completed in V6l** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget` at V6k (`eb7e6b4e`), which also discharged §5.4; `PortalDepthMaskRenderer` (`eced67d0`) and both offscreen viewports on the Vulkan arm (`2e8b8b91`) at V6l, reported in §5.5.17. Unblocked by amendment 2 (a stencil dimension on `GpuPipelineDescription` plus `IGpuPassEncoder.SetStencil`) and amendment 3 (a layered sampled view per render target, sample-count pipeline variants). `PortalTunnelPresentation` needed no port — it draws into the backbuffer — and still has no Vulkan arm; see §5.5.17's V7 list. | GL pixel gate, 3-run connected, validation-clean Vulkan run, and connected paperdoll + particle captures on both backends | | **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 | @@ -579,6 +580,7 @@ tenth pair with no consumer at all; see the V6e report. | **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 | +| **V6l** ✅ | **Particles, the portal mask and the viewports**, reported in §5.5.17. Three commits, one per contract amendment: **1** instanced vertex input (`GpuVertexLayout` per-binding stride and input rate, `BindVertexBuffer(binding, …)`, `GpuVertexFormat.UInt1`) plus `ParticleRenderer`'s RHI arm, the standalone particle texture cache on both arms, and the stride-equals-the-uploaded-record gate for every RHI vertex layout (`b1ad1d48`); **2** the stencil dimension (`StencilTest` + `GpuStencilState` + `SetStencil`), `portal_depth` as a committed shader pair, `PortalDepthMaskRenderer`'s RHI arm, and the ambient stencil/colour-mask restore (`eced67d0`); **3** a layered sampled view per Vulkan render target, sample-count pipeline variants for `WbDrawDispatcher`, the composite texture cache on both arms, and the backend-derived viewport V orientation (`2e8b8b91`). | GL pixel gate per commit (3.20e-05, 2.31e-05, 3.55e-05; band 9–31 px), App tests 4,121/4,129/4,129 and complete Release suite 9,184/9,192/9,192, GL connected `-Runs 3` at 3/3 on both columns per commit, one validation-layer Vulkan run at 0 errors / 0 warnings per commit, and connected Vulkan paperdoll and particle captures inspected against GL | | **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 | @@ -2152,6 +2154,195 @@ population. 6. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (unchanged; a V8 item). +#### 5.5.17 V6l (2026-07-28): particles, the portal mask and the viewports land, and three amendments are taken + +The three things §5.5.16 left on the V7 list as *blocked on a contract decision* +are all on the Vulkan arm. Each amendment is its own reviewed commit with +`GpuContractTests` coverage, in the same shape as `InverseAlpha` (V4c), +`UByte4UInt` (V4d), `ColorFormat` (V6d) and `UniformSkyParams` (V6e). + +**1. Instanced vertex input, and particles** (`b1ad1d48`). §5.5.16's option (i): +`GpuVertexLayout` grows a per-binding notion — binding index, stride, input rate +— `GpuVertexAttribute` names the binding it is fed from with a default of 0, and +`IGpuPassEncoder.BindVertexBuffer` takes a binding index. Every layout written +before the slice keeps its exact meaning through `GpuVertexLayout.Interleaved`, +which is one vertex-rate binding 0, and a contract test asserts that as a +requirement rather than trusting it. Both backends carry the rate natively and at +no cost: `VK_VERTEX_INPUT_RATE_INSTANCE` on the pipeline, +`glVertexAttribDivisor` recorded once into the pipeline's VAO where it survives +every later attribute rebind. + +`GpuVertexFormat.UInt1` comes with it and is **necessary to it**: +`particle.vert` declares `layout(location = 6) in uint aTextureIndex` and the +amendment's whole premise is that no shader is edited. Same kind-distinction +`UByte4UInt` was added for — GL requires `glVertexAttribIPointer`, Vulkan +requires `R32_UINT`, and the float path would reinterpret the value's bits rather +than approximate them. + +`ParticleRenderer.Rhi.cs` is V4e's content as a second arm. Five pipelines +replace the imperative `glBlendFunc` switch; the per-flight VAO/VBO pool +disappears because every ring allocation inside a frame is already distinct +memory that lives until the frame retires; the binding-9 table is not bound at +all; the pass is borrowed from `IWorldPassScope`. Depth tests but does not write, +compare is `Less` and alpha-to-coverage is off — the ambient GL state particles +have always drawn under rather than a choice. + +**The fifth defect of the compiles-clean class, found by running rather than by +validation.** The first Vulkan particle frame threw: +`TextureCache.AcquireParticleTexture` was bindless-only, so the standalone +particle texture cache did not exist on a backend without GL. It exists on both +arms now — everything about it that matters, the owner sharing, the bounded +unowned LRU and retirement behind the frame-flight fence, was already +backend-neutral, and only how one entry is created and destroyed differs, which +is what `IStandaloneBindlessTextureBackend` is for. + +**The durability fix V6k earned.** That slice found the sky declaring a 32-byte +stride against a 36-byte `AcDream.Core.Terrain.Vertex` and noted that *every* +`.Rhi.cs` arm restates a CPU record's footprint from memory while only sky had a +test. `RhiVertexLayoutStrideTests` is that test for the rest — world mesh, +terrain, sky, retained-UI sprite, debug line and both particle bindings, each +against the record or the producer's own float count, plus two sweeps over all +seven for attributes that reach past their stride or name an undeclared binding. + +**2. The stencil dimension, and the portal mask** (`eced67d0`). §5.5.16 defect 2. +The amendment splits the way core Vulkan 1.3 splits: the ENABLE and the +attachment intent are baked as `GpuPipelineDescription.StencilTest` (false by +default, so no pipeline changed), and the per-draw compare, three outcome ops, +reference and both masks are a `GpuStencilState` the pipeline carries as a +DEFAULT and `IGpuPassEncoder.SetStencil` overrides — exactly the split cull mode, +front face and depth write already have. The four stencil dynamic states are +declared **only** by a pipeline that tests stencil, because declaring one obliges +every draw with that pipeline to have set it. + +`PortalDepthMaskRenderer` gets three pipelines rather than one: depth COMPARE is +not dynamic in the contract and the punch's two passes differ in it — mark tests +`LEQUAL` and writes no depth, punch tests `ALWAYS` and writes — with the seal a +third. All three write no colour, which is what retail's "COLOR-INVISIBLE +triangle fan" means. The fan is expanded to a triangle LIST on the CPU, exactly: +triangle *i* is (v0, v[i+1], v[i+2]). + +**This is the one renderer in the campaign whose two arms do not share a shader +source, and the reason is worth recording.** `portal_depth.vert`'s clip planes +have to travel in the `TerrainClip` uniform block at binding 2 — already +precisely this shape, already read by `terrain_modern.vert` and `sky.vert` — but +on GL that binding is held globally by `ClipFrame` for terrain, so a portal draw +that rebound it would leave every later terrain draw in the frame reading the +wrong region. The GL arm therefore keeps its inline program. +`PortalDepthShaderParityTests` is the tripwire: retail's far-Z constant +(`0.99999988`, `DrawPortalPolyInternal` 0x0059bc90), #129's capped mark-bias +expression and the eight-half-plane loop are asserted to appear in both. Both are +deleted at V11. **9/10 shader pairs now compile to SPIR-V.** + +Two GL-side gaps closed while the state was being extended, both §7.1 rule 1's +class rather than new work: `GlAmbientCapabilityState` now saves and restores the +stencil test, function, ops and both masks — the punch draws mid-frame among +renderers that are still raw GL and assume the test is off — and the COLOUR MASK, +which had no consumer until a colour-invisible pipeline existed and whose absence +would have blacked out every raw-GL renderer after such a pass. + +`PortalTunnelPresentation` was re-read and confirmed as V6k left it: it clears +depth and draws into the active viewport, binds no framebuffer of its own, and +needs no port for §5.4's sake. It remains unported on the Vulkan arm — the +composition uses `NullLocalPlayerTeleportPresentation` there — which is an +absence on the V7 list, not a defect. + +**3. The offscreen viewports** (`2e8b8b91`). §5.5.16 defect 3 named two backend +fixes; both are here, and running it found two more the note could not have +known about. + +- **The layered sampled view.** An attachment view must be + `VK_IMAGE_VIEW_TYPE_2D` and the table's descriptor array is `sampler2DArray`, + so V6k made `RegisterTexture` refuse the attachment view and name this fix. + `VulkanGpuTexture` now creates a SECOND, layered view over the same image for a + colour render target — one image, one allocation, two ways of looking at it, + legal without any creation flag — and `SampledView` is what the table registers + for every texture, so the question disappears rather than being answered. +- **Sample-count pipeline variants.** `WbDrawDispatcher`'s five pipelines became + a `MeshPipelineSet` with two instances, selected at bind time from the LIVE + pass rather than from the scope. When the backbuffer is single-sampled the two + sets are one object. The offscreen target's depth attachment also had to take + the device's own combined depth/stencil format rather than the contract enum's + literal `D24_UNORM_S8_UINT`, because a pipeline bakes one depth/stencil format + and the same pipelines draw in both passes. +- **Third, found by running: entity APPEARANCE composites were still + bindless-only**, so no entity with a palette override could be drawn on the + Vulkan arm at all — the doll being one, and every creature and player besides. + This is why V6j and V6k could not have run connected on Vulkan even had they + tried. `RhiCompositeTextureArrayBackend` has existed since V6i-2 with no + production consumer; it has one now, and nothing about the cache changed. +- **Fourth, found by the first successful capture: the doll rendered upside + down.** `UiViewport` has flipped V since V4a because a GL framebuffer's origin + is bottom-left; a Vulkan image's is top-left and the negative viewport height + stores the rendered image that way round, so the same flip stands the doll on + its head. That is a property of the backend that made the texture, so + `IUiViewportRenderer` answers `TextureIsBottomUp` and the widget asks. The line + this replaces had predicted exactly this failure since it was written. + +`IWorldPassScope.Publish` is on the interface for this: the dispatcher's RHI arm +borrows its pass rather than opening one, so a viewport that opens a pass of its +own has to publish it for the span of the draw. It does not nest — the world +phase has closed its own pass by the time private presentation runs. + +**Gates, per commit.** Release build green. App tests **4,121 / 4,129 / 4,129** +against the 4,109 baseline (three contract tests and nine layout tests at commit +1; three contract, four shader-parity and one render-state-cache test at commit +2); complete Release suite **9,184 / 9,192 / 9,192**. Strict GL offline pixel +gate against `08ffe141`: **3.20e-05 (18 px)**, **2.31e-05 (13 px)**, +**3.55e-05 (20 px)** of 563,200, all inside the documented 9–31 band. 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**. One solution-wide run at each of commits 2 +and 3 reported a single App failure that did not reproduce in the App suite alone +or in a second solution-wide run — the documented rerun-singly flake class; the +runner did not surface the name and it is not carried forward as a claim. + +**The two captures the offline gate cannot reach, connected and inspected.** + +- **The paperdoll on Vulkan** (`artifacts/v6l-vk-paperdoll3`): upright, in + armour, at the right scale, over a transparent background, and + indistinguishable from the same capture on GL taken minutes later + (`artifacts/v6l-gl-paperdoll`) — which is also the no-regression check for the + V-orientation change. +- **Particles on Vulkan** (`artifacts/v6l-vk-poi` versus `artifacts/v6l-gl-poi`, + cropped 4× at `artifacts/crop-vk-glow.png` and `crop-gl-glow.png`): Holtburg's + forge plume and its field of glint sprites draw in the same places with the + same alpha compositing on both backends, the puffs differing only in phase + because two launches cannot agree on an emitter's age. The offline scene's + emitters are outside its fixed view, which is why this is connected. + +**One thing the slice got for free and did not go looking for.** A connected +Vulkan run teleported into the Marketplace and rendered the interior — walls, +floor, banners, per-cell lighting — which is the first time anything has drawn +`EnvCellRenderer`'s Vulkan arm (`artifacts/v6l-vk-mp`). It is a single +eyes-on frame, not a gate, so §5.5.16 defect 4 is narrowed rather than closed: +the arm demonstrably renders an interior, and the doorway clip, portal +visibility and deferred-alpha paths remain unexercised by anything automated. + +**The V7 defect list, updated.** + +1. ~~Particles~~, ~~the portal mask~~, ~~the viewports~~ — all landed. + **`PortalTunnelPresentation` has no Vulkan arm**: the composition uses + `NullLocalPlayerTeleportPresentation`, so a portal transit shows no tunnel on + that backend. Absence, not defect; it is the last raw-GL world-adjacent + renderer. +2. **`EnvCellRenderer`'s Vulkan arm is proven by one eyes-on frame and nothing + automated.** Narrowed from §5.5.16 item 4. The deferred-alpha path and the + doorway scissor are still unexercised, and no connected route visits an + interior — the durable fix §5.1 already names is adding an interior stop to + `connected-r6-soak.route.txt`. +3. **MSAA must be forced off for the differential** (unchanged; measured at 8.8% + of the frame at 4× in §5.5.16). +4. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (unchanged; a V8 + item). +5. **The portal depth mask's two arms do not share a shader source.** Guarded by + `PortalDepthShaderParityTests` and resolved by deletion at V11, but it is the + one duplication the campaign carries and it should not be extended. +6. **The paperdoll's V orientation is now backend-derived** rather than constant. + The appraisal viewport shares the class and the code and was still not + itself driven — the same half-discharge §5.1's V6k row records, carried + forward. + ### 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