diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 434f0831..b16f7ab2 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,42 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
+## #427 — Hard line below the horizon from altitude: the sky dome was fogged with a 0.2 floor and the world fog range came from the streaming window, not the keyframe
+
+**Status:** ✅ FIXED 2026-08-23 (owner report, Candeth Keep/Holtburg heights: "a cut off where the sky ends and the world background void begins").
+**Component:** rendering / sky + world fog (retail parity)
+
+**Root causes (two April-2026 stand-ins, neither registered):**
+1. `sky.frag` fogged every non-additive sky layer and clamped the blend with
+ `SKY_FOG_FLOOR = 0.2` ("mechanism unknown, workaround until pinned",
+ `97fc1b51`). The dome's rim (a sphere cut 500 m below the camera, radius
+ 1050 m) was therefore 20 % texture / 80 % fog against a frame cleared to
+ 100 % fog — a visible seam wherever terrain doesn't cover the rim (altitude,
+ water, map edge). Retail: `GameSky::Draw @0x00506FF0` draws the sky with
+ fixed-function fog DISABLED (`SetFFFogEnable(LScape::m_override_enabled ?
+ 1 : 0)` around the sky draw) — fog touches the dome only under an
+ AdminEnvirons override; additive layers are never fogged
+ (`SetFFFogAlphaDisabled(1)` at `D3DPolyRender::SetSurface 0x59c882`).
+2. `WorldRenderFrameBuilder` overwrote the UBO's authored fog range with
+ 0.7 × near radius .. 0.95 × far radius (538..2189 m) at every hour and
+ weather (`ACDREAM_FOG_START_MULT`/`_END_MULT`). Retail sets
+ `D3DRS_FOGSTART/FOGEND` straight from the keyframe's `MinWorldFog/
+ MaxWorldFog` (`SkyDesc::GetWorldFog @0x00500CE0` → `LScape::UseTime` →
+ `RenderDeviceD3D::SetFFFogProperties @0x005A2F70`, no draw-distance
+ scaling; `Render::zfar` is a constant 4000 m). Authored Sunny: day
+ 150–2400 m, dawn/dusk 90–800, night 0–400; Rainy day 150–1500.
+
+**Fix:** sky pass `ApplyFog = environOverrideActive && !additive`, no floor;
+the frame builder leaves `SceneLightingUbo.Build`'s authored `FogParams.xy`
+alone; the two env multipliers are deleted from `RuntimeOptions`. Guard:
+`SkyFogRuleTests` (source-level; the sky renderer has no hermetic harness);
+`sky.frag.spv` re-pinned in `VulkanShaderManifestTests` with the reason.
+**Visible consequence (owner gate owed):** night and rain fog are now
+retail's much shorter ranges; the dome's horizon tint is the authored
+texture alone. The streaming window (≥ 2.1 km from the player) still sits
+at/beyond every authored fog end except Sunny day's 2400 m, where the far
+edge lands ~96 % fogged.
+
## #426 — Every solid-colour (untextured) polygon on every object client-wide was invisible: mesh extraction misread NO_POS_UVS as "no positive face"
**Status:** ✅ FIXED 2026-08-23 (found on the Holtburg windmill axle, GfxObj
diff --git a/docs/research/2026-04-23-sky-fog.md b/docs/research/2026-04-23-sky-fog.md
index d1fd2345..5f361163 100644
--- a/docs/research/2026-04-23-sky-fog.md
+++ b/docs/research/2026-04-23-sky-fog.md
@@ -1,3 +1,13 @@
+> **CORRECTION 2026-08-23 (#427).** This note assumed retail fogs its sky
+> meshes ("the sky dome mesh is at a distance where fog contribution
+> dominates"). It does not: `GameSky::Draw @0x00506FF0` disables fixed-
+> function fog around the entire sky draw unless an AdminEnvirons fog
+> override is active. The dome's horizon colour is the authored texture plus
+> the keyframe tint. The `SKY_FOG_FLOOR = 0.2` clamp that this assumption
+> produced was removed the same day. Q3's terrain-fog conclusions (authored
+> `MinWorldFog/MaxWorldFog` applied directly, no scaling) stand and are now
+> what the client does.
+
# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace)
**Date:** 2026-04-23
diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag
index bff4f922..f02ebbe8 100644
--- a/src/AcDream.App/Rendering/Shaders/sky.frag
+++ b/src/AcDream.App/Rendering/Shaders/sky.frag
@@ -57,19 +57,24 @@ void main() {
vec3 rgb = sampled.rgb * vTint;
- // SHOULD-FIX S2/mech NOTE N-3 (OP4 review-fix round, 2026-08-11):
- // PlayerOption DisableDistanceFog forces FogMode.Off (uFogParams.w
- // == 0) — mesh_modern.frag/terrain_modern.frag both gate their own
- // fog blend on this same word (`if (mode == 0) return lit;`), but the
- // sky dome's blend here read only uApplyFog (the CPU per-submesh
- // "is this layer foggable at all" flag) and never uFogParams.w, so
- // toggling the option stopped terrain/objects fading into fog while
- // the dome's horizon band kept blending toward fog color.
+ // Retail draws the sky with fixed-function fog DISABLED:
+ // GameSky::Draw @0x00506FF0 saves GetFFFogEnable, calls
+ // SetFFFogEnable(LScape::m_override_enabled ? 1 : 0), draws every sky
+ // object, and restores it. So the dome is fogged ONLY while an
+ // AdminEnvirons fog override is active; the horizon tint otherwise
+ // comes from the authored dome textures and the keyframe tint, and the
+ // frame's fog-coloured clear meets the dome's own colour at the rim.
+ // The CPU sets uApplyFog = 1 only for (override active && surface not
+ // Additive — SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface
+ // 0x59c882). uFogParams.w == 0 is PlayerOption DisableDistanceFog
+ // (LScape::m_fFogEnabled -> SetFFFogUserDisabled), honoured here like
+ // every other fogged pass. The 2026-04-27 "SKY_FOG_FLOOR = 0.2" clamp
+ // was a stand-in for this rule before it was read from retail; it
+ // left the dome's rim at 80 % fog against a 100 % fog clear, which
+ // showed as a hard line below the horizon from altitude (2026-08-23).
int fogMode = int(uFogParams.w);
if (uApplyFog > 0.5 && fogMode != 0) {
- const float SKY_FOG_FLOOR = 0.2;
- float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR);
- rgb = mix(uFogColor.rgb, rgb, skyFogFactor);
+ rgb = mix(uFogColor.rgb, rgb, vFogFactor);
}
float flash = uFogParams.z;
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index e0c93c12..a363d5a2 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -316,7 +316,7 @@
},
{
"stage": "frag",
- "sourceSha256": "2ddf210d69b0c4a3c0870eecfb0ccba2097d93b365729739402bfe91e3b120d0",
+ "sourceSha256": "b1f3b924af2040c909337c02461ed6795b5320fb04b5bb371ce1546feead567c",
"compiled": true
}
]
diff --git a/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv
index c8b5e567..477ff9fe 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
index 38cedc8f..a8c2236b 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -372,18 +372,19 @@ public sealed partial class SkyRenderer : IDisposable
// with texture alpha and keyframe transparency.
_params.SurfOpacity = sub.SurfOpacity;
- // Retail D3DPolyRender::SetSurface at 0x59c882 calls
- // SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
- // is set on the Surface — so the sun, moon, stars, and any
- // additive cloud sheet are drawn WITHOUT fog. Skipping fog
- // on additive surfaces keeps the sun bright at horizon
- // dusk/dawn (where fog would otherwise dim it to fog color).
- // Non-additive sky meshes (the dome/background layers)
- // still mix toward keyframe fog with the floor mitigation
- // in sky.frag. That restores the broad green/purple Rainy
- // DayGroup tint behind the cloud sheet while raw-additive
- // 0x08000023 remains unfogged and keeps the pink detail.
- _params.ApplyFog = sub.DisableFog ? 0f : 1f;
+ // Retail GameSky::Draw @0x00506FF0 draws the whole sky with
+ // fixed-function fog DISABLED unless an AdminEnvirons fog
+ // override is active (SetFFFogEnable(LScape::
+ // m_override_enabled ? 1 : 0) around the sky draw). Within
+ // that, D3DPolyRender::SetSurface at 0x59c882 calls
+ // SetFFFogAlphaDisabled(1) for Additive surfaces (sun,
+ // moon, stars, additive cloud sheets), so those are never
+ // fogged. The dome's horizon tint is the authored texture
+ // plus keyframe tint — not fog — which is why the frame's
+ // fog-coloured clear meets the dome cleanly at its rim.
+ // (Until 2026-08-23 every non-additive layer was fogged
+ // with a 0.2 floor; see sky.frag for the symptom.)
+ _params.ApplyFog = environOverrideActive && !sub.DisableFog ? 1f : 0f;
// Sky meshes need per-object wrap mode driven by the
// mesh's authored UV range, not by TexVelocity:
diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
index 28c0c246..36aaf465 100644
--- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
+++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
@@ -500,17 +500,18 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
in atmosphere,
camera.Position,
(float)_worldTime.DayFraction);
- float fogStart = _ranges.NearRadius
- * LandblockSize
- * _options.FogStartMultiplier;
- float fogEnd = _ranges.FarRadius
- * LandblockSize
- * _options.FogEndMultiplier;
- ubo.FogParams = new Vector4(
- fogStart,
- fogEnd,
- ubo.FogParams.Z,
- ubo.FogParams.W);
+ // FogParams.xy stay the keyframe's authored MinWorldFog/MaxWorldFog
+ // that SceneLightingUbo.Build wrote (AtmosphereSnapshot.FogStart/
+ // FogEnd). Retail sets D3DRS_FOGSTART/FOGEND straight from those
+ // (SkyDesc::GetWorldFog @0x00500CE0 -> LScape::UseTime ->
+ // RenderDeviceD3D::SetFFFogProperties @0x005A2F70) with no draw-
+ // distance scaling; its far plane is a constant 4000 m
+ // (Render::zfar @0x0081EC88). Until 2026-08-23 this site overwrote
+ // them with a range derived from the streaming window (0.7 x near
+ // radius .. 0.95 x far radius = 538..2189 m at every hour and
+ // weather), which thinned retail's night (0..400 m) and rain
+ // (150..1500 m) fog and hid the fact that the sky pass was being
+ // fogged at all (see SkyRenderer / sky.frag).
_lightingUbo?.Upload(ubo);
RenderingDiagnostics.EmitLight(
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index 635d513e..be16cda0 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -81,8 +81,6 @@ public sealed record RuntimeOptions(
/// Diagnostic-only initial orbit elevation in degrees. Null keeps
/// the normal camera default.
float? InitialOrbitPitchDegrees,
- float FogStartMultiplier,
- float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets,
StreamingWorkBudgetOptions StreamingWorkBudgets,
string? VulkanDeviceOverride,
@@ -207,8 +205,6 @@ public sealed record RuntimeOptions(
InitialOrbitPitchDegrees:
TryParseOrbitPitchDegrees(
env("ACDREAM_ORBIT_PITCH_DEGREES")),
- FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
- FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
// Physical-device override, matched as a decimal index first and then
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
index dfb4fc1f..1ca73d65 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
@@ -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",
diff --git a/tests/AcDream.App.Tests/Rendering/Sky/SkyFogRuleTests.cs b/tests/AcDream.App.Tests/Rendering/Sky/SkyFogRuleTests.cs
new file mode 100644
index 00000000..f737a1c7
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Sky/SkyFogRuleTests.cs
@@ -0,0 +1,86 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering.Sky;
+
+///
+/// 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.
+///
+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.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
index 2a6d7776..9698b34c 100644
--- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
@@ -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()
{