diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index 474afc38..40897fc1 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -549,7 +549,7 @@ because sample positions are not specified across implementations.
| **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** ⏸ **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 |
+| **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) |
@@ -1278,7 +1278,7 @@ than deleting it outright.
with no world renderers. Its gate is an offline Vulkan launch reaching the real
composition with a captured UI frame, a converging ownership ledger at shutdown,
and the strict GL offline gate unmoved.
-3. **V4t**, the texture stack, which the world arm cannot be written without.
+3. ~~**V4t**, the texture stack~~ — done at `b8bcaa3e`/`565c351f` (§5.5.11).
4. **The world arm** — V4c/V4d's content behind the construction-time backend
selection at the frame-root seam.
@@ -1392,6 +1392,108 @@ exercised the moment it exists; and §5.5.8's recorded one-binding-two-buffers
hazard is still unfired, because nothing on the Vulkan arm yet binds the same
storage binding to two buffers in one frame. The world arm will.
+#### 5.5.11 V4t (2026-07-28): the world's data model is backend-neutral
+
+§5.5.9's step 3 is done, in two commits — `b8bcaa3e` (terrain) and `565c351f`
+(everything else). Every world batch now carries a `GpuTextureSlot` rather than
+a raw 64-bit `ARB_bindless_texture` handle, and the four interim
+`GlBindlessHandleTable` instances in `WbDrawDispatcher`, `EnvCellRenderer`,
+`TerrainModernRenderer` and `ParticleRenderer` are gone. The Vulkan world arm's
+prerequisite is met: its batch structs are already the shape it needs.
+
+**§5.2's reason not to reach the device's table expired rather than being
+worked around.** That paragraph's argument was the flush — `GlGpuDevice` drains
+its dirty table runs inside `FlushBeforeDraw`, which only an encoder-recorded
+draw reaches, so a raw-GL renderer would sample a stale table. It assumed V4c
+would move those renderers onto the encoder. §5.5.6 closed the GL re-land, so
+they stay raw GL through to V10 and "wait for the encoder" became an indefinite
+block on the one slice the Vulkan world arm cannot be written without. The
+resolution is two internal members — `FlushTextureTable` (the drain, factored
+out of `FlushBeforeDraw`) and the already-existing `TextureTableGlName` — which
+each raw-GL renderer calls immediately before its own draw, in the exact shape
+its private table had. Both are deleted with the raw-GL world path.
+
+**The slice was narrower than §5.3 sized it, in a way worth recording.**
+§5.3 wrote "porting the whole texture stack", and the V4t row said "onto
+`IGpuTexture`/`IGpuSampler`". What landed keeps texture CREATION and residency
+with the caches — `ManagedGLTextureArray`, `CompositeTextureArrayCache`'s GL
+backend, `TerrainAtlas` and `TextureCache`'s array upload all still speak raw
+GL — and moves only the TABLE ENTRY to the device, keyed 1:1 by the caller's
+already-resident handle. That is the whole of what the data model needed, and
+it is what let one slice retype nine source and five test files instead of
+rewriting three texture caches. Creating world textures through `IGpuTexture`
+is real remaining work and it belongs with the Vulkan world arm, which is the
+first thing that cannot use a GL handle at all.
+
+**Slot release is stricter than the tables it replaces.** The interim tables
+never released — entries accumulated for the renderer's lifetime, by design and
+by comment. The device's table is capped at
+`GpuBindingModel.TextureTableCapacity` (16,384), so an unreleased entry is now
+a leak with an end, and every producer retires its own: the composite backend
+and the particle backend at `MakeNonResident`, and `ObjectMeshManager` when a
+retiring atlas's physical retirement completes. Teardown deliberately does not
+release — the device dies with its callers, and deferring through a
+possibly-disposed retirement queue would turn a clean shutdown into a throw.
+
+**The default value became load-bearing.** `BindlessTextureLocation` could
+signal "not resolved" with handle 0 because no texture has handle 0. A slot
+index has no spare value — `default(GpuTextureSlot)` is real slot 0 — so the
+type is now a struct storing its slot one-based, making `default` exactly
+`Unresolved`, with a test pinning that a location naming slot 0 is
+distinguishable from it. Everywhere else the sentinel was already exact:
+`GpuTextureSlot.Unassigned` is `0xFFFFFFFF`, which is `common.glsl`'s
+`ACDREAM_TEXTURE_NONE`, so the classify path's readiness test and the particle
+billboard's untextured branch kept their meaning unchanged.
+
+**`GroupKey` ordering is preserved because the key never ordered anything.**
+Handle→slot is a bijection, so the same (entity, batch) pairs bucket together.
+The key reaches equality, hashing and the scene-digest fingerprints, never a
+comparator: opaque and translucent groups sort by cull mode then camera
+distance, delayed alpha by viewer distance then submission ordinal, and group
+enumeration follows the dictionary's insertion order, which a changed hash does
+not disturb. Both sides of the render-shadow comparison hash the slot index the
+same way, so the digest value moving is invisible to it.
+
+**`SkyRenderer` keeps its `GlBindlessHandleTable`,** which is why that class
+still exists. Its textures are minted by the sky renderer itself from
+`TextureCache.GetOrUpload`'s raw GL texture names — the one world path this
+slice did not retype — so it would be the sole consumer interning handles it
+produced, a different shape from the rest of the stack. The offline gate also
+masks the sky band, so the only automated instrument available here could not
+see a regression in it. V4f owns that renderer and should retire the table and
+the class together.
+
+**Gate results.** GL offline pixel gate against `cb2a70b8`: 3.02e-05 at
+`b8bcaa3e`, and 5.50e-05 / 3.91e-05 on two captures at `565c351f`. The first of
+those is above the documented 15–23 px band, so a control was measured rather
+than assumed: two same-commit captures at `565c351f` differ by 19 px, and a
+capture at `b8bcaa3e` versus one at `565c351f` differs by **9 px** — fewer than
+the same-commit control, across two different commits. Maximum channel delta is
+41–52 in every pair including the controls, so the differing pixels come from
+one flickering population rather than from moved geometry. Both commits passed
+`tools/run-repeat-connected-gate.ps1 -Runs 3` at 3/3 RENDERED on the desktop
+witness and the client capture, and one Vulkan composition-host run each with
+`VK_LAYER_KHRONOS_validation` **proven inserted by the loader** — zero errors,
+zero warnings, converged ownership ledger. App tests 4,077 / 3 skips and the
+complete Release suite 9,140 / 5, both baselines plus the two tests added.
+
+**One connected run died and is filed, not attributed.** The first 3-run
+attempt at `b8bcaa3e` lost one run to an unhandled
+`OpenGL returned unexpected fence wait status NoError (0x0)` in the render
+loop. It did not reproduce in the following three runs at that tree nor in
+three interleaved runs at `cb2a70b8`, and V4t creates, deletes and waits on no
+fence. `#251` records it with the evidence; it is the same below-the-API shape
+§5.5.1–§5.5.3 documented four instances of on this driver, but that is a
+hypothesis and the issue says so.
+
+**Next is the world arm** — V4c/V4d's content behind the construction-time
+backend selection at the frame-root seam. Two things this slice hands it:
+§5.5.8's one-binding-two-buffers hazard is still unfired and the world arm is
+what will fire it, since `WbDrawDispatcher` and `EnvCellRenderer` each own
+instance and batch buffers and both bind bindings 0, 1, 3, 4 and 5 in one
+frame; and the world path's own texture creation still has to reach
+`IGpuTexture`, because a Vulkan draw cannot sample a GL handle.
+
### 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
diff --git a/src/AcDream.App/Rendering/GlBindlessHandleTable.cs b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
index 330541c8..1b6b34b0 100644
--- a/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
+++ b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
@@ -18,16 +18,20 @@ namespace AcDream.App.Rendering;
/// accumulate for the renderer's lifetime, exactly like the world atlas and
/// composite-texture caches it draws handles from.
///
-/// This class, plus the per-renderer SSBO it backs, is deleted at
-/// V4c/V4d/V4e when each renderer moves onto IGpuDevice's own
-/// retirement-gated table — see the campaign doc's §5.2 for why V2 cannot
-/// reach that table yet.
+/// Campaign V slice V4t retired every instance but one. The world
+/// texture stack now produces GpuTextureSlot directly, so
+/// WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer and
+/// ParticleRenderer share GlGpuDevice's single retirement-gated table
+/// instead of interning handles themselves — see that class's
+/// RegisterWorldTextureHandle and the campaign doc's §5.5.11.
///
-/// Each renderer that needs the indirection (WbDrawDispatcher,
-/// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns its own
-/// instance. Nothing requires index agreement between renderers: each rebinds
-/// its own buffer to binding=9 immediately before its own draw call, so two
-/// renderers may legitimately assign different slots to the same handle.
+/// The remaining owner is SkyRenderer, whose textures are minted
+/// from TextureCache.GetOrUpload's raw GL texture names — the one world
+/// path V4t did not retype — so it is the sole consumer interning handles it
+/// produced itself. Slice V4f retires that table, and this class with it.
+/// Nothing requires index agreement between the sky's table and the device's:
+/// each rebinds its own buffer to binding=9 immediately before its own draw
+/// call, so the same handle may legitimately hold different slots in the two.
///
internal sealed class GlBindlessHandleTable
{