diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 3c53de88..ee55cd67 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -493,6 +493,32 @@ AMD's unspecified read was usually returning the resolved image already; and "usually" is exactly the property that makes an unspecified read useless as an instrument. +**The sky has two clocks, and V7 pinned the second one.** +`ACDREAM_SKY_PHASE_SECONDS` (V7, `RuntimeOptions.SkyAnimationPhaseSeconds`, +consumed by `SkyRenderer.AnimationPhaseSecondsOverride`) replaces the wall-clock +elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value. +**Unset — the default, and every ordinary run — keeps the wall clock**, so +nothing the user or the offline gate sees changes unless a gate asks. + +It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override +pin only the *other* sky clock: the Dereth date, which chooses the day group, +the keyframe and the sun angle. The cloud sheet does not read that clock at all +and is not supposed to — retail's clouds drift with real time regardless of the +date — so a route that pins the world clock still cannot make two launches agree +about where the clouds are. That is the whole reason this gate masks its top 280 +rows, and the V6m smoke pair measured the same population costing **89% of an +18.52% whole-frame GL-versus-Vulkan difference**. + +Pinning the phase is instrument determinism rather than a workaround, on the +same footing as `ACDREAM_DAY_GROUP`: it is one input to a UV offset, it is off +by default, and no shipping code path reads it. The alternative was +`-MaskTopPixels`, which would have permanently blinded the campaign's strictest +instrument to the whole sky — one of the five surfaces the offline gate already +cannot see. **The backend differential gate forces it on both launches**; the +offline gate keeps its mask, because a same-commit GL pair has other reasons to +disagree up there (the sun moves with the Dereth clock, which that gate does not +pin). + **Coverage.** Terrain and terrain blending, scenery, static world meshes, water, fog, and the entire retained UI (vitals, spell bar, toolbar, chat, radar). diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a70933c3..c00325b0 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1086,6 +1086,11 @@ internal sealed class LivePresentationCompositionPhase // Slice V6k: and V4t's world-handle seam on the device, which // retired the last per-renderer GlBindlessHandleTable. (AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)host.GpuDevice) + { + // Campaign V slice V7: null unless ACDREAM_SKY_PHASE_SECONDS + // is set, which is every run but a differential gate's. + AnimationPhaseSecondsOverride = d.Options.SkyAnimationPhaseSeconds, + } : new SkyRenderer( host.GpuDevice, host.GpuFrameLifetime, @@ -1093,7 +1098,10 @@ internal sealed class LivePresentationCompositionPhase ?? throw new InvalidOperationException( "A backend without a GL context must publish a world pass scope."), content.Dats, - foundation.TextureCache), + foundation.TextureCache) + { + AnimationPhaseSecondsOverride = d.Options.SkyAnimationPhaseSeconds, + }, static value => value.Dispose()); // Campaign V slice V6l: particles exist on BOTH arms. The GL arm is // unchanged; the RHI arm compiles the two particle pairs from SPIR-V, diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index a86ca48a..058beb7a 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -92,6 +92,34 @@ public sealed unsafe partial class SkyRenderer : IDisposable // real time (independent of the day-fraction clock). private readonly DateTime _startedAt = DateTime.UtcNow; + /// + /// Campaign V slice V7: pins the sky's scroll phase to a fixed number of + /// seconds instead of reading the wall clock, so two launches agree. + /// null — the default, and what every ordinary run gets — keeps the + /// wall clock. + /// + /// Why the sky needs its own pin when the world clock is already + /// pinnable. Two independent clocks drive this renderer. The Dereth clock + /// chooses the day group, the keyframe and the sun angle, and + /// AcdreamCycleTimeOfDay freezes it; the differential route presses it + /// three times to land on noon. The cloud sheet does not use that clock at + /// all — TexVelocityX/Y accumulate against real elapsed time, by + /// design, because retail's clouds drift independently of the date. So a + /// route that pins the world clock still cannot make two launches agree + /// about where the clouds are, and the V6m smoke pair measured what that + /// costs: 89% of an 18.52% whole-frame difference lived in the top 240 + /// rows. + /// + /// This is instrument determinism, not a rendering change. It + /// alters one input to a UV offset, it is off unless + /// ACDREAM_SKY_PHASE_SECONDS is set, and nothing in the shipping + /// client reads it. The alternative — masking the sky band in the gate — + /// would have permanently blinded the strictest instrument the campaign has + /// to the whole sky, which is one of the five surfaces the offline gate + /// already cannot see (plan §5.1). + /// + internal float? AnimationPhaseSecondsOverride { get; init; } + // Configurable render distance — retail uses ~1e6; anything larger // than the scene far plane works. public float Near { get; set; } = 0.1f; @@ -288,7 +316,8 @@ public sealed unsafe partial class SkyRenderer : IDisposable // override + transparency fade + luminosity cap. var replaces = PickReplaces(group, dayFraction); - float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds; + float secondsSinceStart = AnimationPhaseSecondsOverride + ?? (float)(DateTime.UtcNow - _startedAt).TotalSeconds; for (int i = 0; i < group.SkyObjects.Count; i++) { diff --git a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs index eae19197..a2207b05 100644 --- a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs +++ b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs @@ -197,17 +197,55 @@ internal sealed class RhiWorldTextureArrayFactory(IGpuDevice device) : IWorldTex /// sampler. Both address modes are registered up front because a shared atlas is /// sampled both ways by different batches — the same reason the GL array holds /// two resident bindless handles. -/// There is no anisotropy knob. The GL array reads -/// graphicsDevice.MaxSupportedAnisotropy at construction. The RHI -/// sampler takes , and the -/// quality preset does not reach this class yet — the world arm that draws -/// through these arrays is the next slice, and it is the one that can gate a -/// filtering change visually. Until then this asks for the same trilinear -/// filtering with anisotropy 1, and says so rather than guessing. +/// Anisotropy is asked for as a ceiling rather than read back. See +/// . /// /// internal sealed class RhiWorldTextureArray : IWorldTextureArray { + /// + /// Campaign V slice V7: the anisotropy the shared world atlases are sampled + /// with, and the value that makes this arm's filtering the GL arm's. + /// + /// What RETAIL does, which is the same thing. + /// RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all + /// sixteen sampler stages and, at 0x005a4230, issues + /// SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) — + /// 0xA is D3DSAMP_MAXANISOTROPY, and the value is the device's + /// own reported cap rather than a setting. So "as much anisotropy as this + /// device has" is retail's rule, not a WorldBuilder habit acdream inherited, + /// and asking for 1 here was a divergence from retail as well as from the + /// shipping backend. + /// + /// What the GL arm does. ManagedGLTextureArray sets + /// GL_TEXTURE_MAX_ANISOTROPY to OpenGLGraphicsDevice + /// .MaxSupportedAnisotropy — the driver's own + /// GL_MAX_TEXTURE_MAX_ANISOTROPY, read once at construction — and its + /// two resident bindless handles are built from sampler objects + /// (WrapSampler/ClampSampler) that set the same value. So the + /// shipping backend asks for "as much anisotropy as this device has," + /// unconditionally, and NOT for the quality preset's level; the preset + /// reaches only TerrainAtlas. + /// + /// Why a literal rather than a device read. The pinned RHI + /// contract (plan §3.3) has no anisotropy limit on + /// and is frozen, but it does not need + /// one: VulkanGpuSampler already clamps + /// to + /// VkPhysicalDeviceLimits.maxSamplerAnisotropy, so requesting a + /// ceiling IS requesting the device maximum. Vulkan guarantees that limit is + /// at least 16 wherever the samplerAnisotropy feature is supported — + /// which this backend requires — and 16 is where every desktop driver caps, + /// so the request and the GL arm's read land on the same number. + /// + /// Why it is not cosmetic. Measured at V7 on the differential's + /// Holtburg stop: with this at 1, Vulkan's roof shingles, distant scenery and + /// every grazing-angle surface sample a coarser mip than GL's, which is a + /// visible blur and was the largest single population in the first + /// GL-versus-Vulkan pair outside the animated sky. + /// + private const float WorldArrayAnisotropy = 16f; + private readonly IGpuDevice _device; private readonly IGpuTexture _texture; private readonly GpuTextureFormat _format; @@ -266,10 +304,16 @@ internal sealed class RhiWorldTextureArray : IWorldTextureArray // ResolveSlot a field read on the hot path. _clampSlot = device.RegisterTexture( texture, - device.CreateSampler(GpuSamplerDescription.WorldClamp)); + device.CreateSampler(GpuSamplerDescription.WorldClamp with + { + MaxAnisotropy = WorldArrayAnisotropy, + })); _wrapSlot = device.RegisterTexture( texture, - device.CreateSampler(GpuSamplerDescription.WorldRepeat)); + device.CreateSampler(GpuSamplerDescription.WorldRepeat with + { + MaxAnisotropy = WorldArrayAnisotropy, + })); } catch { diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 3cfb93be..d6cc0e38 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -53,6 +53,7 @@ public sealed record RuntimeOptions( string? UiProbeScript, string? AutomationArtifactDirectory, int? ForcedDayGroupIndex, + float? SkyAnimationPhaseSeconds, float FogStartMultiplier, float FogEndMultiplier, ResidencyBudgetOptions ResidencyBudgets, @@ -119,6 +120,18 @@ public sealed record RuntimeOptions( NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), ForcedDayGroupIndex: TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")), + // Campaign V slice V7 instrument determinism: pins the sky's UV + // scroll phase — the cloud sheet — to a fixed elapsed-seconds value + // instead of the wall clock, so two launches of the differential + // gate agree about where the clouds are. ACDREAM_DAY_GROUP and the + // AcdreamCycleTimeOfDay override pin the OTHER sky clock (day group, + // keyframe, sun angle); this one is independent of both by design, + // because retail's clouds drift with real time rather than with the + // date. Unset — the default and every ordinary run — keeps the wall + // clock. Negative values are accepted: the offset is taken modulo 1 + // per axis, so any finite number is a valid phase. + SkyAnimationPhaseSeconds: + TryParseFloat(env("ACDREAM_SKY_PHASE_SECONDS")), FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f, ResidencyBudgets: ResidencyBudgetOptions.Parse(env), diff --git a/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs index 1393b596..239e7c37 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs @@ -95,8 +95,22 @@ public sealed class RhiWorldTextureArrayTests .Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal)) .Select(registration => registration.Sampler), ]; - Assert.Contains(GpuSamplerDescription.WorldClamp, samplers); - Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers); + // Campaign V slice V7: both address modes, and BOTH anisotropic. The GL + // array asks for GL_TEXTURE_MAX_ANISOTROPY = the driver's maximum on + // every world atlas, unconditionally; asking for 1 here made Vulkan + // sample a coarser mip than GL on every grazing-angle surface, which the + // first GL-versus-Vulkan pair saw as blurred roof shingles and distant + // scenery. `with { MaxAnisotropy = 1f }` recovers the campaign's shared + // description so this stays an assertion about ONE dimension. + Assert.Contains(GpuSamplerDescription.WorldClamp, samplers.Select(Isotropic)); + Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers.Select(Isotropic)); + Assert.All(samplers, sampler => Assert.True( + sampler.MaxAnisotropy > 1f, + $"world atlas sampler {sampler.AddressU} asked for anisotropy " + + $"{sampler.MaxAnisotropy}; the GL arm always asks for the device maximum.")); + + static GpuSamplerDescription Isotropic(GpuSamplerDescription sampler) => + sampler with { MaxAnisotropy = 1f }; } /// diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 8b53ff8c..aec07424 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -358,6 +358,39 @@ public sealed class RuntimeOptionsTests .ForcedDayGroupIndex); } + /// + /// Campaign V slice V7. The sky has two clocks and this pins the one + /// ACDREAM_DAY_GROUP cannot reach — the cloud sheet's UV scroll, which + /// accumulates against real elapsed time. Unset must stay unset: every + /// ordinary run keeps the wall clock, and only the backend differential gate + /// asks for a pin. + /// + [Fact] + public void SkyPhaseOverride_IsReadOnceIntoTypedOptions() + { + Assert.Null( + RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).SkyAnimationPhaseSeconds); + Assert.Null( + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "not-a-number" })) + .SkyAnimationPhaseSeconds); + // Zero is a real request, not "unset": it is the cloud sheet's authored + // origin and the value the gate script pins by default. + Assert.Equal( + 0f, + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "0" })) + .SkyAnimationPhaseSeconds); + Assert.Equal( + 12.5f, + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "12.5" })) + .SkyAnimationPhaseSeconds); + } + [Fact] public void DiagnosticFlags_RespectExactValueOne() { diff --git a/tools/run-backend-differential-gate.ps1 b/tools/run-backend-differential-gate.ps1 index 00a3682d..ed041d48 100644 --- a/tools/run-backend-differential-gate.ps1 +++ b/tools/run-backend-differential-gate.ps1 @@ -15,7 +15,7 @@ MSAA off, ACDREAM_DAY_GROUP pinned, at every deterministic checkpoint of the connected route. - THREE THINGS ARE FORCED, and each is load-bearing. + FOUR THINGS ARE FORCED, and each is load-bearing. 1. ACDREAM_MSAA_SAMPLES=0 ON BOTH LAUNCHES. Multisample resolve positions are explicitly unspecified across implementations. Plan section 5.5.16 @@ -31,7 +31,20 @@ with wall time; two launches minutes apart cannot agree about either. The route additionally pins the client-only time-of-day override. - 3. A DESKTOP WITNESS per run, with the guards the repeat-run gate + 3. ACDREAM_SKY_PHASE_SECONDS pinned ON BOTH LAUNCHES. The day group and the + time-of-day override pin ONE of the sky's two clocks -- the one that + chooses the keyframe and the sun angle. The cloud sheet runs on the + other: TexVelocityX/Y accumulate against real elapsed time by design, + because retail's clouds drift independently of the date, so no amount of + world-clock pinning makes two launches agree about where they are. The + V6m smoke pair measured the cost of leaving it: 89% of an 18.52% + whole-frame difference lived in the top 240 rows. Pinning the phase is + instrument determinism -- it is off in every ordinary run, it changes one + input to a UV offset, and it is what lets this gate keep the SKY under + strict comparison instead of masking it, which is what -MaskTopPixels + would have cost. + + 4. A DESKTOP WITNESS per run, with the guards the repeat-run gate (run-repeat-connected-gate.ps1) learned the hard way. The client's own capture is the differential's subject, but a blank frame reads back as a valid PNG (plan section 5.5.2), so a second instrument that shares @@ -71,6 +84,11 @@ .PARAMETER DayGroup Sky day-group index pinned on both launches. Default 0. +.PARAMETER SkyPhaseSeconds + Elapsed-seconds value the sky's UV scroll is pinned to on both launches. + Default 0 -- the cloud sheet's authored origin. Any finite value works: the + offset is taken modulo 1 per axis. See forced item 3 above. + .PARAMETER MaskTopPixels Height in pixels of a top band excluded from every comparison. DEFAULT 0 -- nothing is masked, and the gate is a strict identity check on the whole @@ -112,6 +130,7 @@ param( [int]$Tolerance = 2, [double]$MaxDifferentFraction = 0.001, [int]$DayGroup = 0, + [double]$SkyPhaseSeconds = 0, [int]$MaskTopPixels = 0, [int]$MinRenderedBytes = 500000, [int]$CooldownSeconds = 15, @@ -191,8 +210,10 @@ function Invoke-BackendRun([string]$Backend) { $env:ACDREAM_RETAIL_UI = '1' $env:ACDREAM_DEVTOOLS = '0' $env:ACDREAM_DAY_GROUP = "$DayGroup" - # Both launches, always. See the .DESCRIPTION note 1. + # Both launches, always. See the .DESCRIPTION notes 1 and 3. $env:ACDREAM_MSAA_SAMPLES = '0' + $env:ACDREAM_SKY_PHASE_SECONDS = + $SkyPhaseSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture) $env:ACDREAM_RENDER_BACKEND = $Backend $env:ACDREAM_UI_PROBE_SCRIPT = $effectiveRoute $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $dir @@ -253,6 +274,7 @@ function Invoke-BackendRun([string]$Backend) { Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue + Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue $strayInput = @(Select-String -Path $log -Pattern 'ScrollUp|ScrollDown|ZoomIn|ZoomOut|CameraZoom' ` -CaseSensitive -ErrorAction SilentlyContinue) @@ -362,6 +384,7 @@ $report = [pscustomobject][ordered]@{ MaxDifferentPixelFraction = $MaxDifferentFraction MsaaSamples = 0 DayGroup = $DayGroup + SkyPhaseSeconds = $SkyPhaseSeconds MaskTopPixels = $MaskTopPixels Runs = @($gl, $vk) Pairs = @($pairs)