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.