fix #427: sky drawn without fog like retail; world fog range is the keyframe's authored MinWorldFog/MaxWorldFog

Two April stand-ins, neither registered, met at the horizon from altitude as
a hard line between the dome's rim and the fog-coloured clear:

1. sky.frag fogged every non-additive sky layer with a 0.2 floor. Retail's
   GameSky::Draw @0x00506FF0 disables fixed-function fog around the whole
   sky draw unless an AdminEnvirons fog override is active (SetFFFogEnable(
   LScape::m_override_enabled ? 1 : 0)); additive layers stay unfogged via
   SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface 0x59c882. The sky
   pass now sets ApplyFog only for (override active && !additive), with no
   floor.
2. WorldRenderFrameBuilder overwrote the authored fog range with one derived
   from the streaming window (538..2189 m always). Retail sets FOGSTART/
   FOGEND straight from the keyframe's MinWorldFog/MaxWorldFog
   (SkyDesc::GetWorldFog @0x00500CE0 -> SetFFFogProperties @0x005A2F70) with
   no draw-distance scaling; zfar is a constant 4000 m. The builder now
   leaves SceneLightingUbo.Build's values alone; ACDREAM_FOG_START_MULT /
   _END_MULT are deleted from RuntimeOptions.

Guards: SkyFogRuleTests (source-level, the sky renderer has no hermetic
harness); sky.frag.spv re-pinned in VulkanShaderManifestTests with the
reason. Research note 2026-04-23-sky-fog.md carries a correction banner.
App hermetic 6,070/0, Core 4,707/0 (Release). Owner look gate owed: night
and rain fog are now retail's shorter authored ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 13:42:42 +02:00
parent 517d17b4b3
commit cc42edc8e3
11 changed files with 180 additions and 62 deletions

View file

@ -47,7 +47,12 @@ public sealed class VulkanShaderManifestTests
["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
["sky.frag.spv"] = "ae0d9e3e1e1b5742dd986cb39c62ea6e71e19783feea8b86a5cd940504d6047e",
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
// 0.2 floor and is now applied only under an AdminEnvirons fog
// override, retail's GameSky::Draw @0x00506FF0 rule (see
// SkyFogRuleTests). A deliberate default-path change, reviewed
// with the world-fog-range fix in the same commit.
["sky.frag.spv"] = "b3b544829f2dd85be04b6b16d78a0490e1fe7884590cb541b95756b7d7511620",
["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",

View file

@ -0,0 +1,86 @@
using System;
using System.IO;
using Xunit;
namespace AcDream.App.Tests.Rendering.Sky;
/// <summary>
/// Retail draws the sky with fixed-function fog DISABLED (GameSky::Draw
/// @0x00506FF0: SetFFFogEnable(LScape::m_override_enabled ? 1 : 0) around
/// the sky draw) and sets the world fog range straight from the keyframe's
/// authored MinWorldFog/MaxWorldFog (SkyDesc::GetWorldFog @0x00500CE0 ->
/// RenderDeviceD3D::SetFFFogProperties @0x005A2F70, no draw-distance
/// scaling). Two April-2026 stand-ins contradicted that — a 0.2 "fog floor"
/// on the dome and a fog range derived from the streaming window — and
/// together showed as a hard line below the horizon from altitude
/// (2026-08-23). These source guards keep both retail rules in place; the
/// renderer itself needs a GPU and the installed DATs, so there is no
/// hermetic behavioural harness for it.
/// </summary>
public sealed class SkyFogRuleTests
{
[Fact]
public void SkyFragmentShaderFogsTheDomeOnlyThroughUApplyFogAndWithoutAFloor()
{
string frag = File.ReadAllText(Path.Combine(ShaderRoot(), "sky.frag"));
string code = StripLineComments(frag);
Assert.DoesNotContain("SKY_FOG_FLOOR", code, StringComparison.Ordinal);
Assert.DoesNotContain("max(vFogFactor", code, StringComparison.Ordinal);
Assert.Contains("if (uApplyFog > 0.5 && fogMode != 0)", code, StringComparison.Ordinal);
Assert.Contains("rgb = mix(uFogColor.rgb, rgb, vFogFactor);", code, StringComparison.Ordinal);
}
[Fact]
public void SkyRendererAppliesFogOnlyUnderAnAdminEnvironsOverrideAndNeverOnAdditiveLayers()
{
string source = File.ReadAllText(Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.cs"));
string code = StripLineComments(source);
Assert.Contains(
"_params.ApplyFog = environOverrideActive && !sub.DisableFog ? 1f : 0f;",
code,
StringComparison.Ordinal);
Assert.DoesNotContain("_params.ApplyFog = sub.DisableFog ? 0f : 1f;", code, StringComparison.Ordinal);
}
[Fact]
public void WorldFrameBuilderLeavesTheAuthoredFogRangeAlone()
{
string source = File.ReadAllText(Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "WorldRenderFrameBuilder.cs"));
string code = StripLineComments(source);
// SceneLightingUbo.Build writes FogParams.xy from AtmosphereSnapshot
// (the keyframe's authored range, SceneLightingUboTests pins that);
// nothing downstream may overwrite them from the streaming window.
Assert.DoesNotContain("ubo.FogParams = new Vector4(", code, StringComparison.Ordinal);
Assert.DoesNotContain("FogEndMultiplier", code, StringComparison.Ordinal);
Assert.DoesNotContain("FogStartMultiplier", code, StringComparison.Ordinal);
}
private static string StripLineComments(string source)
{
var lines = source.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
int idx = lines[i].IndexOf("//", StringComparison.Ordinal);
if (idx >= 0)
lines[i] = lines[i][..idx];
}
return string.Join('\n', lines);
}
private static string ShaderRoot() =>
Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders");
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
directory = directory.Parent;
return directory?.FullName
?? throw new InvalidOperationException("Could not locate repository root.");
}
}

View file

@ -167,8 +167,6 @@ public sealed class RuntimeOptionsTests
Assert.Null(opts.UiProbeScript);
Assert.Null(opts.AutomationArtifactDirectory);
Assert.False(opts.ExactAutomationFramebuffer);
Assert.Equal(0.7f, opts.FogStartMultiplier);
Assert.Equal(0.95f, opts.FogEndMultiplier);
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
}
@ -339,26 +337,6 @@ public sealed class RuntimeOptionsTests
Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius);
}
[Fact]
public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently()
{
var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_FOG_START_MULT"] = "0.625",
["ACDREAM_FOG_END_MULT"] = "1.125",
}));
Assert.Equal(0.625f, parsed.FogStartMultiplier);
Assert.Equal(1.125f, parsed.FogEndMultiplier);
var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_FOG_START_MULT"] = "not-a-number",
["ACDREAM_FOG_END_MULT"] = "",
}));
Assert.Equal(0.7f, invalid.FogStartMultiplier);
Assert.Equal(0.95f, invalid.FogEndMultiplier);
}
[Fact]
public void DayGroupOverride_IsReadOnceIntoTypedOptions()
{