acdream/tests/AcDream.Core.Tests/World/SkyStateTests.cs
Erik 05a8a7209f fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
Two independent investigations (in-house decomp re-check + two
external agent reports) converged on the same root cause for the
"too blue-white sky" symptom:

acdream computed SunColor = DirColor × DirBright and AmbientColor =
AmbColor × AmbBright. Retail computes them from the magnitude of a
specially-shaped sun vector instead. Per the named retail decomp:

  SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353):
    sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
    sunVec.y = cos(P_rad)                    ← NOT scaled by DirBright
    sunVec.z = DirBright × sin(P_rad)

  PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118):
    D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)

  SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117):
    SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...)

Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in
general — the magnitude varies with sun pitch/heading. That's what
gives retail's "sun feels stronger when it's overhead, ambient warms
up at midday" behavior we were missing.

Added SkyStateProvider.RetailSunVector(kf) that builds the vector
verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|.
SunDirectionFromKeyframe normalizes the same vector (replaces our
geometrically-clean spherical convention which didn't match retail's
deliberate Y-decoupled-from-heading shape).

Tests:
- Replaced the linear-interp assumption in
  Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW
  inputs (DirColor, AmbBright, etc.) — those still lerp linearly;
  the composite SunColor doesn't, intentionally.
- Added 4 golden-value tests for the new formulas
  (RetailSunVector_AtZenith, _AtHorizonNorth,
  SunColor_UsesRetailMagnitudeNotDirBrightDirectly,
  AmbientColor_BoostsByTwentyPercentOfSunVectorLength).
- Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness
  test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude
  with the new expected magnitude.

User visually verified — acdream's sky shifted from blue-white toward
the warm tint retail shows at the same keyframe.

1227 tests pass.
2026-04-27 22:42:53 +02:00

181 lines
6.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class SkyStateTests
{
[Fact]
public void Default_Has4Keyframes()
{
var sky = SkyStateProvider.Default();
Assert.Equal(4, sky.KeyframeCount);
}
[Fact]
public void Interpolate_AtExactKeyframe_ReturnsThatFrameData()
{
var sky = SkyStateProvider.Default();
var noon = sky.Interpolate(0.5f); // noon keyframe
// Noon sky color should be near white (1.0 ish).
Assert.InRange(noon.SunColor.X, 0.9f, 1.1f);
Assert.InRange(noon.SunColor.Y, 0.9f, 1.1f);
}
[Fact]
public void Interpolate_BetweenKeyframes_LerpsRawInputs()
{
var sky = SkyStateProvider.Default();
var dawn = sky.Interpolate(0.25f);
var noon = sky.Interpolate(0.5f);
var midPt = sky.Interpolate(0.375f);
// The RAW per-channel inputs (DirColor, AmbColor, brightness scalars)
// lerp linearly between adjacent keyframes — that's the retail-faithful
// separate-channel interpolation. The composite SunColor / AmbientColor
// properties intentionally do NOT lerp linearly (their magnitude
// depends nonlinearly on heading/pitch/brightness via the retail
// sun-vector formula), so we assert on the raw inputs here.
float low = System.Math.Min(dawn.DirColor.Y, noon.DirColor.Y);
float high = System.Math.Max(dawn.DirColor.Y, noon.DirColor.Y);
Assert.InRange(midPt.DirColor.Y, low, high);
}
[Fact]
public void RetailSunVector_AtZenith_HasMagnitudeEqualToBrightness()
{
// Sun straight up (P=90°): cos(P)=0, sin(P)=1.
// sunVec = (sin(H)×B×0, 0, B×1) = (0, 0, B)
// |sunVec| = B
var kf = new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
DirColor: Vector3.One,
DirBright: 1.5f,
AmbColor: Vector3.One,
AmbBright: 0.3f,
FogColor: Vector3.One,
FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(kf);
Assert.InRange(v.Length(), 1.49f, 1.51f);
}
[Fact]
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
{
// 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);
var v = SkyStateProvider.RetailSunVector(kf);
Assert.InRange(v.Length(), 0.99f, 1.01f);
}
[Fact]
public void SunColor_UsesRetailMagnitudeNotDirBrightDirectly()
{
// At sun pitch 90° (zenith) with H=0, B=2: |sunVec| = 2.
// SunColor = DirColor × |sunVec| = (0.5, 0.5, 0.5) × 2 = (1, 1, 1).
var kf = new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
DirColor: new Vector3(0.5f, 0.5f, 0.5f),
DirBright: 2.0f,
AmbColor: Vector3.One,
AmbBright: 0.3f,
FogColor: Vector3.One,
FogDensity: 0f);
Assert.InRange(kf.SunColor.X, 0.99f, 1.01f);
Assert.InRange(kf.SunColor.Y, 0.99f, 1.01f);
Assert.InRange(kf.SunColor.Z, 0.99f, 1.01f);
}
[Fact]
public void AmbientColor_BoostsByTwentyPercentOfSunVectorLength()
{
// |sunVec| = 1 (horizon north), AmbBright = 0.4, AmbColor = (1,1,1).
// AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)
// = (1,1,1) × (0.4 + 0.2) = (0.6, 0.6, 0.6).
var kf = new SkyKeyframe(
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 0f,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 0.4f,
FogColor: Vector3.One,
FogDensity: 0f);
Assert.InRange(kf.AmbientColor.X, 0.59f, 0.61f);
}
[Fact]
public void Interpolate_Wraps_AcrossMidnight()
{
var sky = SkyStateProvider.Default();
var justAfterMidnight = sky.Interpolate(0.01f);
// Should return finite valid state (not NaN).
Assert.False(float.IsNaN(justAfterMidnight.SunColor.X));
Assert.False(float.IsNaN(justAfterMidnight.AmbientColor.X));
}
[Fact]
public void SunDirectionFromKeyframe_ReturnsUnitVector()
{
var kf = new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south
SunPitchDeg: 70f,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.One,
FogDensity: 0.001f);
var dir = SkyStateProvider.SunDirectionFromKeyframe(kf);
Assert.InRange(dir.Length(), 0.99f, 1.01f);
}
[Fact]
public void WorldTimeService_SyncFromServer_SetsTicks()
{
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(12345.0);
// NowTicks advances by real elapsed time; but immediately after
// sync it should be at or very close to the synced value.
Assert.InRange(service.NowTicks, 12345.0, 12346.0);
}
[Fact]
public void WorldTimeService_DayFraction_RespectsSync()
{
var service = new WorldTimeService(SkyStateProvider.Default());
// Need to aim for dayFraction 0.5 (Gloaming-and-Half, slot 15 since tick 0 = slot 7).
// Sync to (0.5 - 7/16) * DayTicks = (1/16) * DayTicks — 1 slot past Morntide-and-Half = Midsong.
// Actually simpler: target fraction 7/16 (slot 7 = Morntide-and-Half) by syncing to tick 0.
service.SyncFromServer(0);
Assert.InRange(service.DayFraction, 0.43, 0.44); // 7/16 = 0.4375
Assert.True(service.IsDaytime);
}
}