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)