feat(render): Campaign V slice V10 — Vulkan becomes the default backend

THIS CUTOVER AWAITS THE USER'S VISUAL SIGN-OFF. It is not complete. Section 7
of the campaign plan names the V10 sign-off as the only required user stop
besides gate failures, and it has not been given. This commit flips the default
and runs the battery so that the sign-off has evidence in front of it.

ROLLBACK, one line: `git revert` of this commit. It restores the GL default,
the pre-V10 escape-hatch polarity and the gate scripts' inherited backend
together; nothing else has to move with it.

An unset, empty or unrecognised ACDREAM_RENDER_BACKEND now yields
RenderBackendKind.Vulkan. Only `gl` or `opengl`, case-insensitive, selects
OpenGL. The polarity of the typo case flipped with the default and on purpose:
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 it has to
land on Vulkan for the same reason read the other way, because GL is the backend
V11 deletes. `opengl` is honoured beside `gl` because an escape hatch exists to
be found.

Three gate scripts follow the flip. run-offline-pixel-gate.ps1 gains -Backend
(default vulkan) and now FORCES all four determinism levers — backend, day
group, world day fraction, sky phase — plus ACDREAM_MSAA_SAMPLES=0, instead of
inheriting any of them. run-repeat-connected-gate.ps1 and
run-connected-world-lifecycle-gate.ps1 CLEAR ACDREAM_RENDER_BACKEND rather than
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.

TEST PIN UPDATED, flagged as required: RenderBackend_DefaultsToGl becomes
RenderBackend_DefaultsToVulkan, and RenderBackend_AnythingElseStaysOnGl splits
into RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens and
RenderBackend_AnythingElseStaysOnVulkan. Five cases replace two. No other test
is touched, weakened or deleted.

AD-46's divergence-register row moves from "dormant until the V10 cutover" to
live, in this commit, per the same-commit register rule.

Battery, all on the new default:

  complete Release suite    9,222 passed / 5 skipped / 0 failed (9 projects)
                            +5 against the pre-flip 9,217; the +5 are this
                            slice's own escape-hatch cases
  #250 family, singly       4/4 pass (none failed in the whole-suite run)
  repeat connected gate     PASS 3/3 on both columns
  world-lifecycle route     PASS, 0 failures, both sessions graceful at exit 0
  validation layer          inserted at instance AND device level by the loader,
                            zero errors and zero warnings, real frame captured
  GL escape hatch           verified by two offline launches: 4.3.0 Core Profile
                            Context, bindless present, exit 0

Every connected launch in the battery reached Vulkan with no environment
variable set, which is the flip itself under test rather than an assertion
about it.

THE PIXEL GATE IS NOT MET, AND WAS NOT RELAXED. Vulkan against a GL-era capture
taken at this commit through the escape hatch, MSAA off and both clocks pinned:
1.099e-03 masked / 3.764e-02 whole-frame, against a 0.001 threshold. 97.9% of
the difference is in the treeline band, and the masked residual of 619 px — set
against a same-backend control of 10 px — sits entirely on the silhouettes of
distant alpha-blended scenery. That is AD-46's registered population; section
5.5.19 measured the same quantity at 497 px / 8.8e-04. Below the band the two
backends are photometrically identical: mean luminance differs by 0.01 of 255.
No baseline was regenerated and no mask or tolerance was widened.

Two instrument findings are recorded in section 5.5.23. The offline gate's sky
mask is still load-bearing — this slice tried retiring it on the reasoning that
V7's clock pins had made it obsolete, and the control refuted that: two launches
of the same binary still differ by 1,011 px on GL and 482 px on Vulkan, almost
all of it in the band. The default went back to 280 with the measurement written
into the script's help. And the repeat gate's desktop witness needs an
uncontested primary monitor: a first attempt reported 1/3, and the two failing
grabs turn out to be a web browser and Discord composited over the client rect,
not a blank frame — the client's Vulkan capture rendered in all six runs.

Nothing GL, ImGui or Studio is deleted. That is V11's scope and it is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 22:32:25 +02:00
parent 49a309aaaf
commit 122fe8a7e2
8 changed files with 315 additions and 41 deletions

View file

@ -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 |

View file

@ -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

View file

@ -3,10 +3,10 @@ namespace AcDream.App;
/// <summary>
/// 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. <see cref="Vulkan"/> 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. <see cref="Vulkan"/> is what an unset
/// <c>ACDREAM_RENDER_BACKEND</c> now selects; <see cref="Gl"/> remains reachable
/// for one slice by setting that variable to <c>gl</c>, and slice V11 deletes it
/// along with the escape hatch.
///
/// This enum is public only because <see cref="RuntimeOptions"/> is public and
/// exposes it as a property. The backend-neutral
@ -17,9 +17,12 @@ namespace AcDream.App;
/// </summary>
public enum RenderBackendKind
{
/// <summary>OpenGL 4.3 core + bindless + MDI. The default, and the only live backend.</summary>
/// <summary>
/// OpenGL 4.3 core + bindless + MDI. The escape hatch after slice V10,
/// reachable only by <c>ACDREAM_RENDER_BACKEND=gl</c>, deleted at V11.
/// </summary>
Gl,
/// <summary>Vulkan 1.3 core. Dark until Campaign V slice V10 flips the default.</summary>
/// <summary>Vulkan 1.3 core. The default backend as of Campaign V slice V10.</summary>
Vulkan,
}

View file

@ -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(
}
/// <summary>
/// Startup backend request. Only the exact lower-case token <c>vulkan</c>
/// selects Vulkan; everything else — unset, <c>gl</c>, 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 <c>gl</c> and
/// <c>opengl</c> select OpenGL. Everything else — unset, <c>vulkan</c>, or a
/// typo — is Vulkan.
/// </summary>
/// <remarks>
/// 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. <c>opengl</c>
/// is honoured alongside <c>gl</c> 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.
/// </remarks>
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;
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
public bool HasLiveCredentials =>

View file

@ -476,15 +476,16 @@ public sealed class RuntimeOptionsTests
}
/// <summary>
/// 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
/// <c>RuntimeOptions.ParseRenderBackend</c> together.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);

View file

@ -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 =

View file

@ -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 = @()

View file

@ -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.