acdream/tests/AcDream.Core.Tests/World/SkyStateTests.cs
Erik 63b50c5291 fix(sky): retail-faithful keyframe lerp — separate-channel color/bright
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:

  arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
  arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
  arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
  arg3   = lerp(k1.amb_bright, k2.amb_bright, u)
  final  = (arg4.rgb * arg3, ...)

acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
  - retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
  - acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)

For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.

Refactor:
  SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
    SEPARATELY (raw, not pre-multiplied).
  Computed properties SunColor and AmbientColor return the
    post-multiplied product, keeping the shader uniform interface
    (uSunColor / uAmbientColor) unchanged.
  SkyStateProvider.Interpolate lerps each raw channel, then constructs
    a new SkyKeyframe whose computed properties yield the correct
    post-lerp multiply.
  SkyDescLoader now stores raw values without pre-multiplying.
  GameWindow comment updated; no functional change there.
  Default factory + tests updated to use the new constructor parameters
    with DirBright=AmbBright=1.0 (preserving exact existing behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:02:35 +02:00

93 lines
3.1 KiB
C#

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_LerpsColors()
{
var sky = SkyStateProvider.Default();
var dawn = sky.Interpolate(0.25f);
var noon = sky.Interpolate(0.5f);
var midPt = sky.Interpolate(0.375f);
// Midpoint should fall between dawn & noon for sun color Y (green channel).
float low = System.Math.Min(dawn.SunColor.Y, noon.SunColor.Y);
float high = System.Math.Max(dawn.SunColor.Y, noon.SunColor.Y);
Assert.InRange(midPt.SunColor.Y, low, high);
}
[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);
}
}