diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs
index 5acf2d39..0120e84a 100644
--- a/src/AcDream.Core/World/SkyState.cs
+++ b/src/AcDream.Core/World/SkyState.cs
@@ -74,22 +74,15 @@ public readonly record struct SkyKeyframe(
/// (see ).
///
///
- /// Why |sunVec| instead of DirBright directly: retail's
- /// PrimD3DRender::UpdateLightsInternal at 0x0059b57c
- /// (decomp line 424118-424119) computes
- /// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)
- /// from the sun vector SkyDesc::GetLighting built at
- /// 0x00500ac9 (decomp lines 261343-261353):
- ///
- /// sunVec.x = sin(H) × DirBright × cos(P)
- /// sunVec.y = cos(P) // NOT scaled by DirBright
- /// sunVec.z = DirBright × sin(P)
- ///
- /// Because Y is unscaled by DirBright, |sunVec| ≠
- /// DirBright in general — it varies with sun pitch and heading.
- /// Using DirBright alone underweighted the warm directional
- /// term, letting the cool ambient/fog dominate ⇒ acdream rendered
- /// blue-white at keyframes where retail looked warm-gray.
+ /// |sunVec| is retail's D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²)
+ /// scaling (PrimD3DRender::UpdateLightsInternal 0x0059b57c, decomp
+ /// 424118-424119) of the WORLD-space sun vector (LScape::sunlight).
+ /// Because is now the
+ /// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified —
+ /// see that method), |sunVec| == DirBright, so this is effectively
+ /// SunColor = DirColor × DirBright. (A prior bug used the un-transformed
+ /// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~4–5× too bright at dawn/dusk;
+ /// [[reference-retail-ambient-values]].)
///
///
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
@@ -301,21 +294,35 @@ public sealed class SkyStateProvider
}
///
- /// Retail's raw sun vector (NOT normalized) — the same vector
- /// SkyDesc::GetLighting writes at 0x00500ac9
- /// (decomp lines 261343, 261352, 261353):
+ /// Retail's world-space sun vector (NOT normalized): the standard
+ /// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by
+ /// DirBright:
///
- /// sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
- /// sunVec.y = cos(P_rad) // NOT scaled by DirBright
- /// sunVec.z = DirBright × sin(P_rad)
+ /// sunVec.x = DirBright × cos(P) × sin(H)
+ /// sunVec.y = DirBright × cos(P) × cos(H)
+ /// sunVec.z = DirBright × sin(P)
///
- /// Y is unscaled by brightness on purpose — that's what makes
- /// |sunVec| ≠ DirBright in general (the magnitude varies
- /// with pitch/heading, which is the basis for retail's "sun is brighter
- /// in some configurations than others" lighting behavior). The shader's
- /// uSunDir uniform uses the NORMALIZED vector for N·L; the
- /// magnitude feeds intensity and
- /// the ambient brightness boost in .
+ /// so |sunVec| == DirBright exactly (cos²P·(sin²H+cos²H)+sin²P = 1).
+ ///
+ ///
+ /// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]):
+ /// retail's LScape::sunlight read at a dawn keyframe (H=90°, P=0.9°,
+ /// DirBright≈0.224) = (0.2238, ~0, 0.00352) — y≈0, magnitude 0.224 =
+ /// DirBright. That fed level = 0.2·|sunlight| + ambient_level = 0.2·0.224 +
+ /// 0.40 = 0.445, matching the captured SetWorldAmbientLight level.
+ ///
+ ///
+ /// PRIOR BUG: an earlier version returned y = cos(P) (≈1) — the raw
+ /// PRE-transform value the decomp's SkyDesc::GetLighting writes to its
+ /// arg5 (0x00500ac9, before LScape::set_sky_position's world
+ /// transform). Porting that un-transformed vector inflated |sunVec| to
+ /// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost
+ /// () AND the sun colour
+ /// () by ~30% vs retail. The world-space
+ /// form above is what LScape::sunlight actually holds at runtime.
+ ///
+ /// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright)
+ /// feeds the sun-colour intensity and the ambient brightness boost.
///
public static Vector3 RetailSunVector(SkyKeyframe kf)
{
@@ -325,9 +332,9 @@ public sealed class SkyStateProvider
float sinP = MathF.Sin(p);
float B = kf.DirBright;
return new Vector3(
- MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P)
- cosP, // y = cos(P) ← unscaled by B
- B * sinP); // z = B × sin(P)
+ B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H)
+ B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H)
+ B * sinP); // z = DirBright × sin(P)
}
///
diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
index d07d0a64..4ceeddba 100644
--- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
+++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
@@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests
{
// The loader stores DirColor and DirBright RAW. The SunColor property
// composes them via |sunVec| per retail's UpdateLightsInternal at
- // 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²)
- // where the sun vector is built from heading/pitch/brightness with
- // Y unscaled by brightness (decomp 261352).
+ // 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|.
+ // cdb-verified (reference-retail-ambient-values): |LScape::sunlight| ==
+ // DirBright for every keyframe (world-space spherical vector, magnitude
+ // DirBright·sqrt(cos²P+sin²P) = DirBright).
//
// For this region: H=180°, P=70°, B=1.5
- // sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70))
- // = (0, 0.342, 1.410)
- // |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509
+ // sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70))
+ // = (0, -0.513, 1.410)
+ // |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright)
// DirColor.X = 200/255 = 0.7843
- // SunColor.X = 0.7843 × 1.4509 = 1.138
+ // SunColor.X = 0.7843 × 1.500 = 1.1765
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
- Assert.InRange(kf.SunColor.X, 1.13f, 1.15f);
+ Assert.InRange(kf.SunColor.X, 1.17f, 1.18f);
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs
index 1c677204..3d87da00 100644
--- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs
+++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs
@@ -66,24 +66,33 @@ public sealed class SkyStateTests
}
[Fact]
- public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
+ public void RetailSunVector_MagnitudeAlwaysEqualsDirBright()
{
- // Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0.
- // sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0)
- // |sunVec| = 1 regardless of B (because Y is unscaled by B)
- var kf = new SkyKeyframe(
- Begin: 0f,
- SunHeadingDeg: 0f,
- SunPitchDeg: 0f,
- DirColor: Vector3.One,
- DirBright: 2.0f, // anything
- AmbColor: Vector3.One,
- AmbBright: 1f,
- FogColor: Vector3.One,
- FogDensity: 0f);
+ // cdb-verified (2026-06-18, reference-retail-ambient-values): retail's
+ // world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP),
+ // whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright
+ // for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the
+ // horizon — that was the ~30% over-bright bug.)
+ // Horizon north (H=0°, P=0°): (0, B, 0), |.| = B.
+ var horizon = new SkyKeyframe(
+ Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f,
+ DirColor: Vector3.One, DirBright: 2.0f,
+ AmbColor: Vector3.One, AmbBright: 1f,
+ FogColor: Vector3.One, FogDensity: 0f);
+ Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f);
- var v = SkyStateProvider.RetailSunVector(kf);
- Assert.InRange(v.Length(), 0.99f, 1.01f);
+ // Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224
+ // → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright.
+ var dawn = new SkyKeyframe(
+ Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f,
+ DirColor: Vector3.One, DirBright: 0.224f,
+ AmbColor: Vector3.One, AmbBright: 0.40f,
+ FogColor: Vector3.One, FogDensity: 0f);
+ var v = SkyStateProvider.RetailSunVector(dawn);
+ Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224
+ Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1)
+ Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035
+ Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright
}
[Fact]