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() {