diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index d0e40991..bca0e821 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
-| AD-46 | **Vulkan-arm only, dormant until the V10 cutover.** Dense alpha-blended distant scenery (the treeline) resolves differently on the Vulkan backend than on GL, by about 15% of the pixels in the band, even though both arms now request identical sampler state — trilinear, clamp-and-repeat, and the device's maximum anisotropy. What remains is the anisotropic TAP PATTERN, which both the GL and Vulkan specifications leave implementation-defined, and AMD's two drivers do not agree. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19 | Not assumed — narrowed by measurement, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame falls to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual is not a sub-pixel shift (an integer shift search finds none), not a sharpness change (high-frequency energy matches within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what makes it a driver property rather than a bug. | If it is NOT the tap pattern, some other Vulkan sampler or alpha-path difference is hiding behind this row, and it will present after cutover as distant foliage that shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value both arms now request |
+| AD-46 | **LIVE as of Campaign V slice V10 (cutover committed, user sign-off pending); previously Vulkan-arm-only and dormant.** Dense alpha-blended distant scenery (the treeline) resolves differently on the Vulkan backend than on GL, by about 15% of the pixels in the band, even though both arms now request identical sampler state — trilinear, clamp-and-repeat, and the device's maximum anisotropy. What remains is the anisotropic TAP PATTERN, which both the GL and Vulkan specifications leave implementation-defined, and AMD's two drivers do not agree. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19 | Not assumed — narrowed by measurement, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame falls to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual is not a sub-pixel shift (an integer shift search finds none), not a sharpness change (high-frequency energy matches within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what makes it a driver property rather than a bug. | If it is NOT the tap pattern, some other Vulkan sampler or alpha-path difference is hiding behind this row, and it will present after cutover as distant foliage that shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value both arms now request |
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index 415e9855..240a36db 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -442,8 +442,11 @@ the report.
## 5. Slice sequence
Every slice ends with `dotnet build` and the App test suite green, its gate
-passed, and one commit. GL remains the default backend through V9; all Vulkan
-work is dark behind `ACDREAM_RENDER_BACKEND` (default `gl`).
+passed, and one commit. GL remained the default backend through V9; all Vulkan
+work was dark behind `ACDREAM_RENDER_BACKEND` (default `gl`). **Slice V10
+inverted that**: the default is now `vulkan`, and `ACDREAM_RENDER_BACKEND=gl` is
+the escape hatch for one slice — see §5.5.23, and note that the cutover is
+committed but not yet signed off.
**Run the suite in Release: `dotnet test … -c Release`.** Some tests assert
Release-only behaviour and legitimately fail in Debug —
@@ -620,7 +623,7 @@ tenth pair with no consumer at all; see the V6e report.
| **V7** ◐ **partially discharged — §5.5.19** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1` (**built at V6m**), 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.** Starting distance, measured at V6m: 18.52% of the frame at the first stop. **What landed:** the world atlases' missing anisotropy (`ad5f8b68`, retail-anchored at `0x005a4230`), and two instrument pins the gate was silently missing — the cloud sheet's phase and, much larger, the Dereth clock, which had never actually been pinned by anything and was moving 22% of the frame between two captures 45 s apart *in the same run*. Offline GL-versus-Vulkan, both clocks pinned, is now **8.82e-04 below the tree band — inside the threshold**; the treeline is `AD-46`, proven not to be the depth class. **What did NOT land:** a passing connected stop (each carries a named phase exception), per-stop masks in the gate script, an aperture stop for the portal depth mask, ~~the R6 soak on Vulkan~~ (**run and passed at V8 — §5.5.21**), and the RenderDoc capture (**V8 established the cause: RenderDoc is not installed on this machine**). | every differential checkpoint passes; both connected routes green on VK |
| **V8** ⏸ **measured; two floors missed; the cutover call is the user's — §5.5.21** | Perf gate on the RX 9070 XT, uncapped, both backends, same scene, same day. **What landed:** `VulkanFrameGpuMeasurement` (`00e1b321`), without which the Vulkan arm emitted no `[frame-prof]` line at all — `NullRenderFrameGpuMeasurement` was the only caller of `FrameProfiler.FrameBoundary`, so no performance vehicle could be pointed at it; the finding that **the R6 soak is NOT the vehicle §2's founding numbers came from and is biased against Vulkan** by the per-frame swapchain copy its own artifact directory arms; a same-day GL-versus-Vulkan profile on the G5 ordinary-production vehicle; a phase-level CPU attribution on both arms; and **the R6 soak run natively on Vulkan** (PASS, 0 failures, graceful exit), which V7 left outstanding. **Result, in three configurations:** on a stationary LIGHT scene at 780-870 FPS Vulkan is 75.4% cheaper on GPU p50 and 85.3% lighter on allocation but **14.8% more expensive on CPU p50 and 8.8% on p99** — two floors missed; on a stationary DENSE scene (21,024 entities, identical on both arms) **Vulkan wins every row**, including CPU p50/p99 and 18.5% less total process CPU by Windows' accounting; and on the nine-stop route against an identical world **Vulkan wins every row** and renders **27.3% more frames**. The Vulkan-specific cost is **fixed per frame** — 0.148 ms of required WSI/sync calls (present 0.070, submit 0.027, timeline wait 0.026, acquire 0.025) against GL's ~0.014 ms of present — so it dominates an almost-empty frame and disappears into a full one. **The campaign's named cost centre is closed as measured-and-not-worth-it:** bindings 4/6/7/8 cost **0.031 ms for all ~216 draws of the frame**, 2.4% of it. No Vulkan code was changed to chase the miss. **Not taken:** the RenderDoc capture — RenderDoc is not installed on this machine; it carries to V10. | §2 acceptance table; parity is the floor |
| **V9** ✅ **CI green — §5.5.20, §5.5.22** | Linux + CI: 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). **What landed:** the eleven-step job; `ACDREAM_VULKAN_PROBE_FRAMES`, without which the harness cannot terminate unattended; `tools/compile-shaders.ps1` made path-portable; and the report's jq contract pinned by App tests so a rename fails locally rather than in CI. lavapipe clears every gate requirement by source inspection, including the `samplerAnisotropy` V7 made load-bearing. **Deferred:** the physical Linux GPU row, post-cutover, as for Slice L; and Wayland, which no runner offers. **Not attempted, with cause:** a GL-versus-Vulkan pixel comparison — the GL job asserts exit 4 and so has no frame, and the probe renders synthetic scenes rather than the DAT world CI cannot have. | CI green including the new job — **met**: run 30393357552, all four jobs green, lavapipe `llvmpipe (Cpu)` at Vulkan 1.4.318 / Mesa 25.2.8, a 1280x720 / 35,594-byte captured frame, and "all committed .spv match a fresh compile" on Linux |
-| **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** |
+| **V10** ◐ **flipped and measured; PENDING THE USER'S SIGNATURE — §5.5.23** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. **What landed:** `ParseRenderBackend` inverted (unset/typo → Vulkan; only `gl`/`opengl` → OpenGL, the polarity of the typo case flipping with the default because GL is now the backend V11 deletes); `run-offline-pixel-gate.ps1` gained `-Backend` (default `vulkan`) and forces all four determinism levers plus MSAA off instead of inheriting them; the two connected gates **clear** `ACDREAM_RENDER_BACKEND` so they exercise the process default and an ambient override cannot disguise a GL run as one. **Battery:** complete Release suite **9,222 / 5 skipped / 0 failed** (`#250` family also 4/4 singly); repeat connected gate **3/3 on both columns**; connected world-lifecycle route **PASS**, 0 failures, both sessions graceful at exit 0; validation layer proven inserted at instance and device level with **zero errors and zero warnings**; GL escape hatch verified by two offline launches. **Every connected launch reached Vulkan with no environment variable set.** **The pixel gate is NOT met and was not relaxed:** VK against the GL-era capture is **1.099e-03 masked / 3.764e-02 whole-frame**, 97.9% of it in the treeline band, the masked residual entirely on distant alpha-blended scenery silhouettes — `AD-46`, whose register row this slice moves from dormant to live. Below the band the arms are photometrically identical (mean luminance Δ 0.01 of 255). Nothing GL, ImGui or Studio is deleted; that stays V11's. **Rollback: `git revert` of this slice's commit.** | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** |
| **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure |
### 5.2 Why V2's handle table is not the device's table
@@ -3336,6 +3339,140 @@ ever exercised a cache hit. 10/10 clean under the pin. Filed as #255.
**Net effect on the V9 row:** the job is green, and so is the whole workflow —
its first fully green run is the evidence the row was waiting for.
+#### 5.5.23 V10 (2026-07-28): the default is Vulkan, and the signature is outstanding
+
+**This slice does not complete V10.** It flips the default and runs the battery so
+that the one remaining acceptance criterion — the user looking at the client and
+saying it matches retail — has evidence in front of it. §7 names the V10 visual
+sign-off as the campaign's only required user stop besides gate failures, and it
+has not been given. **The cutover is committed but unsigned, and reverting this
+slice's commit restores the GL default, the old escape-hatch polarity and the gate
+scripts' inherited backend in one step.**
+
+**What changed, and only this.** `RuntimeOptions.ParseRenderBackend` inverted: an
+unset, empty or unrecognised `ACDREAM_RENDER_BACKEND` now yields
+`RenderBackendKind.Vulkan`, and only `gl` or `opengl` (case-insensitive) selects
+OpenGL. The polarity of the typo case flipped with the default, deliberately and
+for the reason it had before read the other way round: a misspelling must never
+silently start the backend that cannot carry the client. Before V10 that was
+Vulkan, because it was dark; after V10 it is GL, because V11 deletes it. Five
+test cases replace two in `RuntimeOptionsTests`, and the pin of the old default
+is the only test this slice touches.
+
+Three gate scripts follow. `run-offline-pixel-gate.ps1` gained `-Backend`
+(default `vulkan`) and now **forces** all four determinism levers — backend, day
+group, world day fraction, sky phase — plus `ACDREAM_MSAA_SAMPLES=0`, rather than
+inheriting any of them. `run-repeat-connected-gate.ps1` and
+`run-connected-world-lifecycle-gate.ps1` **clear** `ACDREAM_RENDER_BACKEND`
+instead of setting it, so what they exercise is the process default and an
+ambient override in a caller's shell cannot make a GL run wear the default's
+report. Nothing GL, ImGui or Studio is deleted; that is V11's, untouched.
+
+---
+
+**The battery.**
+
+| Gate | Result |
+|---|---|
+| Complete Release suite | **9,222 passed / 5 skipped / 0 failed** across 9 projects |
+| `#250` family, run singly | **4/4 pass** (none failed in the whole-suite run either) |
+| GL escape hatch, offline | **works** — `ACDREAM_RENDER_BACKEND=gl` reports `4.3.0 Core Profile Context`, bindless present, 1,730,800 B frame, exit 0. Twice. |
+| Repeat connected gate, `-Runs 3` | **PASS 3/3 on both columns** (attempt 2; attempt 1 is below) |
+| Connected world-lifecycle route | **PASS** — 0 failures, 1 warning (`capped: 25 expected world-edge landblock miss(es)`); both sessions exited gracefully with code 0 (239 s capped, 61 s uncapped reconnect), six canonical capped checkpoints plus the reconnect checkpoint |
+| Validation layer proven loaded | **zero errors, zero warnings** — loader prints `Insert instance layer "VK_LAYER_KHRONOS_validation"` and `Inserted device layer`, and the run captured a real 1,753,854 B frame |
+| Offline pixel gate, VK against the GL-era capture | **1.099e-03 masked / 3.764e-02 whole-frame — over threshold, and the excess is `AD-46` alone.** Detail below. |
+
+The suite number is +5 against the pre-flip tree's 9,217/5, and all five are this
+slice's own escape-hatch cases; the App project moved 4,152 → 4,157 exactly.
+**Every connected launch in the battery reached Vulkan with no environment
+variable set** — six repeat-gate runs and both lifecycle sessions log
+`vulkan: capability gate passed`, which is the flip itself under test rather than
+an assertion about it.
+
+---
+
+**The pixel number, honestly.** No baseline was regenerated, and none exists to
+regenerate: the campaign commits no expected PNGs (§5.1 captures the left-hand
+side at a reference commit instead), so the GL-era expected frame was taken at
+this same commit through the escape hatch — which is also what verifies the hatch.
+Both captures: 1280x720, MSAA off, day group 0, day fraction 0.5, sky phase 0.
+
+| Pair | Whole frame | Top 280 masked |
+|---|---|---|
+| GL vs GL, same binary (control) | 1,011 / 921,600 = **1.097e-03** | 10 / 563,200 = **1.78e-05** |
+| VK vs VK, same binary (control) | 482 / 921,600 = **5.23e-04** | 8 / 563,200 = **1.42e-05** |
+| **GL vs VK** | 34,690 = **3.764e-02** | 619 = **1.099e-03** |
+| GL vs VK, second independent pair | 34,649 = 3.760e-02 | 613 = 1.088e-03 |
+
+Three things that table says and a bare "it failed" would not.
+
+1. **97.9% of the whole-frame difference is in rows 0-239** — the treeline and
+ the sky behind it. This scene is the Holtburg overlook, whose top third is
+ solid conifer billboards.
+2. **The masked residual is 619 px against a same-backend control of 10 px**, so
+ it is not capture noise; and every one of those 619 pixels sits on the
+ silhouette of a distant alpha-blended scenery clump, verified by inspecting the
+ difference map rather than inferred. That is `AD-46`'s registered population
+ exactly — the anisotropic tap pattern in dense alpha-blended scenery, which
+ both specifications leave implementation-defined. §5.5.19 measured the same
+ quantity at 497 px / 8.8e-04 on its own capture; this one is 619 px /
+ 1.099e-03. **Same class, 10% over the threshold instead of 12% under it.**
+3. **Below the band the two backends are photometrically identical**: mean
+ luminance differs by **0.01 of 255** over rows 280-719, and 612 pixels of
+ 563,200 differ at all. In the band the Vulkan arm is slightly **darker** — mean
+ luminance -1.93 of 255 across rows 0-279, -4.71 in a tight treeline crop, with
+ 74% of differing pixels darker on Vulkan. So the foliage reads marginally
+ denser, which is the exact symptom `AD-46`'s risk column predicted. At 1:1 the
+ two full frames are not distinguishable by eye; at 4x the fringes are.
+
+`AD-46`'s register row is updated in this commit from "dormant until the V10
+cutover" to live, which is the deviation this slice introduces.
+
+**The gate as written is not met, and this slice does not relax it.** §7.1 rule 2
+forbids widening the mask or the tolerance to turn this green, and neither was
+touched: the mask stayed at the 280 rows it has had since the gate was built, and
+the tolerance at 2 / 0.001. What the slice does instead is put the split, the
+control and the population in front of the person whose sign-off V10 needs.
+
+---
+
+**Two findings from running the battery, both about instruments.**
+
+**1. The offline pixel gate's sky mask is still load-bearing, and V10 nearly
+retired it on an assumption.** The reasoning looked sound — V7 found the two
+clocks that make the sky drift, this script now pins both, therefore the mask is
+obsolete and masking would only hide regressions — so the default was changed to
+0. The control refuted it: with all four levers pinned, two launches of the *same
+binary* still differ by 1,011 px on GL and 482 px on Vulkan, essentially all of it
+in the band, which means an unmasked self-differential fails the 0.001 threshold
+on noise alone. The default was put back to 280 and the measurement written into
+the script's help so the next reader does not repeat the reasoning. Below the
+band, the same controls show 10 px and 8 px — the strictness the mask buys.
+
+**2. The repeat-gate desktop witness needs an uncontested primary monitor, and
+said so all along.** Attempt 1 reported 1/3, with runs 2 and 3 "BLANK" at ~420 KB
+while the client's own capture rendered at ~1.38 MB — the two instruments
+disagreeing, which that script documents as itself a finding. The grabs settle it
+with no ambiguity: run 2's PNG is a **web browser**, run 3's is **Discord**.
+`CopyFromScreen` captured whatever was composited at the client's client-rect,
+which is failure mode one in the script's own header. Attempt 2, minutes later,
+passed 3/3 on both columns. The client's Vulkan frame was correct in all six runs.
+
+Worth recording for V11, which inherits this instrument: the §5.5.2 finding that
+justified making the desktop grab the verdict — a blank GL frame reads back from
+framebuffer 0 as RGBA(0,0,0,0) even where the UI is demonstrably on screen — is a
+statement about **GL's** readback. The Vulkan arm captures by copying the
+swapchain image on the device (`RecordBackbufferCapture`), which is not that path.
+Whether the client capture is now strong enough to be a verdict rather than a
+second column is a question for whoever next touches the gate; this slice only
+notes that the reason it was demoted no longer obviously applies.
+
+---
+
+**Not taken, and why.** The RenderDoc capture the V7 and V8 lists carried is still
+outstanding: V8 established the cause — RenderDoc is not installed on this machine
+— and nothing about the cutover changes that. It carries to V11.
+
### 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
diff --git a/src/AcDream.App/RenderBackendKind.cs b/src/AcDream.App/RenderBackendKind.cs
index 4765f914..64e5ad31 100644
--- a/src/AcDream.App/RenderBackendKind.cs
+++ b/src/AcDream.App/RenderBackendKind.cs
@@ -3,10 +3,10 @@ namespace AcDream.App;
///
/// Campaign V slice V5: which rendering backend the graphical host starts.
///
-/// OpenGL is the default and, through slice V9, the only backend that renders
-/// the game. is dark bring-up — it creates an instance, a
-/// device, a swapchain and runs the capability gate, and nothing more, until the
-/// Vulkan RHI backend lands at V6.
+/// Slice V10 flipped the default. is what an unset
+/// ACDREAM_RENDER_BACKEND now selects; remains reachable
+/// for one slice by setting that variable to gl, and slice V11 deletes it
+/// along with the escape hatch.
///
/// This enum is public only because is public and
/// exposes it as a property. The backend-neutral
@@ -17,9 +17,12 @@ namespace AcDream.App;
///
public enum RenderBackendKind
{
- /// OpenGL 4.3 core + bindless + MDI. The default, and the only live backend.
+ ///
+ /// OpenGL 4.3 core + bindless + MDI. The escape hatch after slice V10,
+ /// reachable only by ACDREAM_RENDER_BACKEND=gl, deleted at V11.
+ ///
Gl,
- /// Vulkan 1.3 core. Dark until Campaign V slice V10 flips the default.
+ /// Vulkan 1.3 core. The default backend as of Campaign V slice V10.
Vulkan,
}
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index f7e0af9a..0468b63c 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -147,8 +147,9 @@ public sealed record RuntimeOptions(
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
- // Campaign V slice V5. Unset, empty, or any unrecognised value means
- // OpenGL: a typo must never silently start the dark Vulkan host.
+ // Campaign V slice V10 flipped this. Unset, empty, or any
+ // unrecognised value now means Vulkan: it is the shipping backend,
+ // and a typo must never silently start the backend V11 deletes.
RenderBackend: ParseRenderBackend(env("ACDREAM_RENDER_BACKEND")),
// Physical-device override, matched as a decimal index first and then
// as a case-insensitive device-name substring. Recorded verbatim in
@@ -178,14 +179,27 @@ public sealed record RuntimeOptions(
}
///
- /// Startup backend request. Only the exact lower-case token vulkan
- /// selects Vulkan; everything else — unset, gl, or a typo — is
- /// OpenGL, which is the shipping backend until Campaign V slice V10.
+ /// Startup backend request. Campaign V slice V10 inverted this: Vulkan is
+ /// the default, and only the explicit escape-hatch tokens gl and
+ /// opengl select OpenGL. Everything else — unset, vulkan, or a
+ /// typo — is Vulkan.
///
+ ///
+ /// The polarity of the typo case is deliberate and it flipped with the
+ /// default. Before V10 an unrecognised token had to land on GL, because
+ /// Vulkan was dark and a typo must never silently start a backend that
+ /// cannot draw. After V10 an unrecognised token must land on Vulkan for the
+ /// same reason read the other way: GL is the retiring backend that slice V11
+ /// deletes, so a typo must never silently pin a process to it. opengl
+ /// is honoured alongside gl because the escape hatch exists to be
+ /// found, and the failure mode of a near-miss spelling here is a process
+ /// that quietly is not on the backend the operator asked to fall back to.
+ ///
private static RenderBackendKind ParseRenderBackend(string? value)
- => string.Equals(value, "vulkan", StringComparison.OrdinalIgnoreCase)
- ? RenderBackendKind.Vulkan
- : RenderBackendKind.Gl;
+ => string.Equals(value, "gl", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, "opengl", StringComparison.OrdinalIgnoreCase)
+ ? RenderBackendKind.Gl
+ : RenderBackendKind.Vulkan;
/// True iff live-mode credentials are present and valid for connecting.
public bool HasLiveCredentials =>
diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
index c15e1e85..f106c469 100644
--- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
@@ -476,15 +476,16 @@ public sealed class RuntimeOptionsTests
}
///
- /// Campaign V slice V5. OpenGL is the shipping backend until slice V10, so
- /// the default must be GL and a typo must never silently start the dark
- /// Vulkan host.
+ /// Campaign V slice V10 flipped the default: Vulkan is the shipping backend
+ /// and an unset variable must select it. Awaiting the user's cutover
+ /// sign-off; the one-line rollback restores GL here and in
+ /// RuntimeOptions.ParseRenderBackend together.
///
[Fact]
- public void RenderBackend_DefaultsToGl()
+ public void RenderBackend_DefaultsToVulkan()
{
Assert.Equal(
- RenderBackendKind.Gl,
+ RenderBackendKind.Vulkan,
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend);
}
@@ -501,17 +502,43 @@ public sealed class RuntimeOptionsTests
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
+ ///
+ /// The V10 escape hatch, and the whole of it. Both spellings are honoured
+ /// because a near-miss here strands the operator on the backend they asked
+ /// to leave.
+ ///
[Theory]
[InlineData("gl")]
+ [InlineData("GL")]
+ [InlineData("Gl")]
[InlineData("opengl")]
+ [InlineData("OpenGL")]
+ public void RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens(string value)
+ {
+ Assert.Equal(
+ RenderBackendKind.Gl,
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
+ }
+
+ ///
+ /// The mirror image of the pre-V10 rule. A typo used to have to land on GL
+ /// because Vulkan was dark; it now has to land on Vulkan because GL is the
+ /// backend slice V11 deletes, and a misspelled fallback that silently works
+ /// is how a process ends up pinned to it.
+ ///
+ [Theory]
[InlineData("")]
[InlineData("vulcan")]
[InlineData("vk")]
[InlineData(" vulkan")]
- public void RenderBackend_AnythingElseStaysOnGl(string value)
+ [InlineData(" gl")]
+ [InlineData("ogl")]
+ public void RenderBackend_AnythingElseStaysOnVulkan(string value)
{
Assert.Equal(
- RenderBackendKind.Gl,
+ RenderBackendKind.Vulkan,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1
index d61f55bc..20ce0cdb 100644
--- a/tools/run-connected-world-lifecycle-gate.ps1
+++ b/tools/run-connected-world-lifecycle-gate.ps1
@@ -262,6 +262,13 @@ function Invoke-Session(
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir
$env:ACDREAM_DUMP_MOVE_TRUTH = $null
$env:ACDREAM_WB_DIAG = $null
+ # Campaign V slice V10: cleared, not set. This route is renderer-agnostic and
+ # its job after the cutover is to exercise whatever backend the process
+ # defaults to -- which is Vulkan. An ambient override inherited from the
+ # caller's shell would quietly turn that into a GL run reported as a default
+ # one, so the variable is cleared the same way every other unwanted knob here
+ # is. Use ACDREAM_RENDER_BACKEND on a manual launch to reach the escape hatch.
+ $env:ACDREAM_RENDER_BACKEND = $null
$env:ACDREAM_COLLISION_SHADOW_EVERY =
if ($CollisionShadowEvery -gt 0) { "$CollisionShadowEvery" } else { $null }
$env:ACDREAM_COLLISION_SHADOW_DIR =
diff --git a/tools/run-offline-pixel-gate.ps1 b/tools/run-offline-pixel-gate.ps1
index 0345b982..f95ef850 100644
--- a/tools/run-offline-pixel-gate.ps1
+++ b/tools/run-offline-pixel-gate.ps1
@@ -16,9 +16,28 @@
those still need the connected route, so slices touching them (V4e, V4g)
must additionally be gated by the user.
- Determinism levers: ACDREAM_DAY_GROUP pins the sky day-group so weather and
- lighting do not drift between runs, and a fixed warm-up lets streaming settle
- before the frame is captured.
+ Determinism levers, all four forced here rather than left to the ambient
+ environment, because a capture that inherits any of them is not comparable
+ against one that did not:
+
+ * ACDREAM_RENDER_BACKEND -- see -Backend. Since slice V10 flipped the
+ process default, "whatever the environment says" is no longer a stable
+ description of what got captured.
+ * ACDREAM_DAY_GROUP -- the sky day group, so weather and the keyframe
+ do not drift between runs.
+ * ACDREAM_WORLD_TIME -- the Dereth day fraction. This moves the SUN, so
+ it moves every lit surface, not just the sky. Slice V7 measured 22.3% of
+ the frame differing between two captures 45 s apart at one stop on one
+ backend with this unpinned.
+ * ACDREAM_SKY_PHASE_SECONDS -- the cloud sheet's UV scroll, which runs on
+ wall time and is independent of the world clock by design.
+
+ ACDREAM_MSAA_SAMPLES is forced to 0 for the same reason the backend
+ differential forces it: multisample resolve positions are explicitly
+ unspecified across implementations, and at 4x they swamp the threshold
+ ninety-fold with two drivers' sample patterns. See plan section 5.5.16.
+
+ A fixed warm-up lets streaming settle before the frame is captured.
.PARAMETER Out
Directory to write this capture into. Created if absent, emptied if present.
@@ -27,6 +46,15 @@
Optional. A directory from a previous capture. When supplied, every PNG is
compared against its namesake and the script fails on any mismatch.
+.PARAMETER Backend
+ Which backend to capture with. DEFAULT 'vulkan', following slice V10's
+ cutover: the gate must capture what the client actually ships.
+
+ Pass 'gl' to capture through the escape hatch -- which is also how this
+ script produces the GL-era left-hand side of a cross-backend comparison, and
+ how the escape hatch itself gets verified. Pass '' to let the process default
+ decide, which is only useful for proving what that default is.
+
.PARAMETER WarmupMs
Milliseconds to let the world stream and settle before capturing. Default
12000, which reliably reaches a fully populated view on the reference machine.
@@ -41,20 +69,49 @@
.PARAMETER MaxDifferentFraction
Fraction of differing pixels allowed. Default 0.001, likewise pinned.
+.PARAMETER WorldDayFraction
+ Dereth day fraction pinned on the launch, in [0, 1). Default 0.5 -- noon, the
+ brightest and flattest lighting the day has, and the value the backend
+ differential pins.
+
+.PARAMETER SkyPhaseSeconds
+ Elapsed-seconds value the sky's UV scroll is pinned to. Default 0, the cloud
+ sheet's authored origin.
+
+.PARAMETER MsaaSamples
+ MSAA sample count forced on the launch. Default 0 -- off, which is what plan
+ section 2's pixel-acceptance contract specifies. Pass -1 to leave the quality
+ preset alone.
+
.PARAMETER MaskTopPixels
- Height in pixels of the sky band excluded from comparison. Default 280.
+ Height in pixels of the sky and treeline band excluded from comparison.
+ Default 280.
- This is not a fudge factor. Two captures at the SAME commit were measured to
- differ in 0.29% of pixels, and the differences were confined to the top ~180
- rows: the sky legitimately animates (clouds scroll, the sun moves) and the
- Dereth clock advances with wall time, so the sky cannot be identical between
- two launches. Everything below the horizon — terrain, blending, scenery,
- static meshes, water, and the whole retained UI — was bit-stable. Masking the
- animated band is what makes the rest a strict identity check rather than
- forcing a loose tolerance that would hide real regressions everywhere else.
+ This is not a fudge factor, and slice V10 re-measured it rather than assuming
+ the V7 clock pins had retired it. Two captures at the SAME commit once
+ differed in 0.29% of pixels, essentially all of it in the top rows, because
+ the sky legitimately animates: clouds scroll on wall time and the Dereth
+ clock moves the sun, and therefore every lit surface. Pinning both clocks --
+ which this script now does -- shrinks that but does NOT remove it. Measured
+ at V10, two launches of the same binary with all four levers pinned:
- Consequence: this gate does NOT cover sky rendering. Slice V4f must be gated
- by the user instead.
+ whole frame, GL vs GL 1,011 px of 921,600 (1.10e-03)
+ whole frame, VK vs VK 482 px of 921,600 (5.23e-04)
+ top 280 masked, GL vs GL 10 px of 563,200 (1.78e-05)
+ top 280 masked, VK vs VK 8 px of 563,200 (1.42e-05)
+
+ So the band still moves between launches on both backends -- enough that an
+ unmasked self-differential on GL fails the 0.001 threshold on noise alone --
+ and everything below it is bit-stable to about ten pixels. Masking the band
+ is what makes the rest a strict identity check instead of forcing a loose
+ tolerance that would hide real regressions everywhere else.
+
+ Consequence, unchanged: this gate does NOT cover sky rendering.
+
+ Do NOT reach for a LARGER value to make a failing gate pass: plan section 7.1
+ rule 2. Its legitimate use is diagnostic -- reporting a masked/unmasked split
+ so a known, registered band (for instance AD-46's treeline) can be quantified
+ separately from the rest of the frame.
.PARAMETER SkipBuild
Skip the Release build (use when the caller already built).
@@ -65,13 +122,22 @@
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-base
git stash pop
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-head -Baseline artifacts/gate-base
+
+.EXAMPLE
+ # Cross-backend: the GL-era capture, then Vulkan against it.
+ tools/run-offline-pixel-gate.ps1 -Out artifacts/v10-gl -Backend gl
+ tools/run-offline-pixel-gate.ps1 -Out artifacts/v10-vk -Baseline artifacts/v10-gl
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Out,
[string]$Baseline,
+ [string]$Backend = 'vulkan',
[int]$WarmupMs = 12000,
[int]$DayGroup = 0,
+ [double]$WorldDayFraction = 0.5,
+ [double]$SkyPhaseSeconds = 0,
+ [int]$MsaaSamples = 0,
[int]$Tolerance = 2,
[double]$MaxDifferentFraction = 0.001,
[int]$MaskTopPixels = 280,
@@ -119,7 +185,17 @@ $env:ACDREAM_DAY_GROUP = "$DayGroup"
$env:ACDREAM_UI_PROBE_SCRIPT = $probe
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $Out
-Write-Step "launching offline client (warmup ${WarmupMs}ms, day group $DayGroup)"
+# The determinism pins, forced rather than inherited. See .DESCRIPTION.
+$invariant = [System.Globalization.CultureInfo]::InvariantCulture
+$env:ACDREAM_WORLD_TIME = $WorldDayFraction.ToString($invariant)
+$env:ACDREAM_SKY_PHASE_SECONDS = $SkyPhaseSeconds.ToString($invariant)
+if ($MsaaSamples -ge 0) { $env:ACDREAM_MSAA_SAMPLES = "$MsaaSamples" }
+else { Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue }
+if ($Backend) { $env:ACDREAM_RENDER_BACKEND = $Backend }
+else { Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue }
+
+$backendLabel = if ($Backend) { $Backend } else { 'process default' }
+Write-Step "launching offline client (backend $backendLabel, warmup ${WarmupMs}ms, day group $DayGroup, day fraction $WorldDayFraction, sky phase $SkyPhaseSeconds, MSAA $MsaaSamples)"
$proc = Start-Process -FilePath $exe -RedirectStandardOutput $log `
-RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized
@@ -155,6 +231,10 @@ finally {
$app | Stop-Process -Force
}
}
+ Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue
+ Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue
+ Remove-Item Env:\ACDREAM_WORLD_TIME -ErrorAction SilentlyContinue
+ Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue
if ($previousLive) { $env:ACDREAM_LIVE = $previousLive }
}
@@ -206,7 +286,7 @@ if ($MaskTopPixels -gt 0) {
$maskPath = Join-Path $Out 'sky-mask.png'
$mask.Save($maskPath, [System.Drawing.Imaging.ImageFormat]::Png)
$mask.Dispose()
- Write-Step "masking the top $MaskTopPixels rows (animated sky)"
+ Write-Step "masking the top $MaskTopPixels rows (animated sky and treeline)"
}
$failed = @()
diff --git a/tools/run-repeat-connected-gate.ps1 b/tools/run-repeat-connected-gate.ps1
index 015b470c..3a25225e 100644
--- a/tools/run-repeat-connected-gate.ps1
+++ b/tools/run-repeat-connected-gate.ps1
@@ -112,6 +112,12 @@ sleep 20000
$env:ACDREAM_RETAIL_UI = '1'
$env:ACDREAM_UI_PROBE_SCRIPT = Join-Path $dir 'probe.txt'
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir
+ # Campaign V slice V10: deliberately cleared rather than set. The blank-world
+ # defect this gate exists for is a property of whichever backend the client
+ # actually starts, so the gate must launch the process default -- Vulkan
+ # after the cutover -- and an ambient override from the caller's shell would
+ # silently make it a GL run wearing the default's report.
+ Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue
$log = Join-Path $dir 'client.log'
# Normal window style: the frame must be composited on screen to be grabbed.