diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index f53a4e70..e2ce24cd 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -501,7 +501,8 @@ 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`, `ClipFrame`, `SceneLightingUboBinding`, timer scopes. `RetailAlphaQueue` untouched. | pixel gate at several checkpoints + connected lifecycle |
+| **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` + `TerrainAtlas`. | pixel gate |
| **V4e** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) |
| **V4f** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) |
@@ -536,6 +537,41 @@ recycling — becomes reachable. Two small, separately pixel-gated changes beat
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.
+
**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
diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs
index 6a4a938b..a2da95f7 100644
--- a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs
+++ b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs
@@ -76,6 +76,7 @@ internal static class GlEnumMapping
{
GpuBlendMode.StraightAlpha => (BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha),
GpuBlendMode.Additive => (BlendingFactor.SrcAlpha, BlendingFactor.One),
+ GpuBlendMode.InverseAlpha => (BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha),
// GpuBlendMode.None never reaches glBlendFunc — blending is disabled instead.
_ => throw new NotSupportedException($"No GL blend factors for {blend}."),
};
diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
index 76685b03..edd4012a 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
@@ -120,6 +120,18 @@ internal enum GpuBlendMode
/// Additive: SrcAlpha, One.
Additive,
+
+ ///
+ /// Retail's inverse-alpha translucency: OneMinusSrcAlpha, SrcAlpha.
+ ///
+ /// Added at slice V4c, which found that `WbDrawDispatcher.ApplyRetailBlend`
+ /// selects three blend functions from each DAT surface's `TranslucencyKind`,
+ /// and this third one had no representation in the V0 contract. Mapping it
+ /// onto would have been a retail-fidelity
+ /// regression, not a simplification, so the contract grew instead.
+ /// `ParticleRenderer` needs it too (slice V4e).
+ ///
+ InverseAlpha,
}
internal enum GpuCompareOp
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
index 44a909fe..28cfc37b 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
@@ -71,6 +71,21 @@ public sealed class GpuContractTests
Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes);
}
+ [Fact]
+ public void BlendModesCoverEveryRetailTranslucencyKind()
+ {
+ // WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
+ // surface's TranslucencyKind. Retail has three, and the V0 contract shipped
+ // with only two — slice V4c found the gap. Mapping InvAlpha onto
+ // StraightAlpha would silently change how every inverse-alpha surface
+ // composites, so the contract has to carry all three.
+ Assert.Equal(4, Enum.GetValues().Length);
+ Assert.Contains(GpuBlendMode.None, Enum.GetValues());
+ Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues());
+ Assert.Contains(GpuBlendMode.Additive, Enum.GetValues());
+ Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues());
+ }
+
[Fact]
public void UnassignedTextureSlotIsNeverAValidIndex()
{