Expand the SkyKeyframe record with retail-exact fog fields (FogStart, FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained for backwards compat with tests that pin it; new shipping code reads FogStart / FogEnd / FogMode directly. Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned against retail observations. Storm mode triggers lightning flashes every 8–30 s via an exponential-decay (200ms τ) flash level that the shader consumes as an additive scene bump. Add SkyDescLoader that parses the Region dat (0x13000000) into LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window + arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready SkyStateProvider builder. Sun/ambient colors are pre-multiplied by DirBright/AmbBright so the shader never needs to know about retail's scalar brightness field. 19 new tests (weather determinism, transition ease, environ override tint, flash decay, dat-load conversion with fog + pre-mult colors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.1 KiB
C#
136 lines
4.1 KiB
C#
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.World;
|
|
|
|
public sealed class SkyDescLoaderTests
|
|
{
|
|
/// <summary>
|
|
/// Hand-build a Region with a minimal sky descriptor to feed the
|
|
/// loader without needing real dat bytes. The LoadFromRegion
|
|
/// separator exists precisely for this — keeps the parsing logic
|
|
/// testable independent of DatCollection.
|
|
/// </summary>
|
|
private static Region MakeRegion(float dirBright, byte rBgrOrder)
|
|
{
|
|
var region = new Region();
|
|
region.PartsMask = PartsMask.HasSkyInfo;
|
|
|
|
var sky = new SkyDesc
|
|
{
|
|
TickSize = 1.0,
|
|
LightTickSize = 2.0,
|
|
};
|
|
|
|
var dg = new DayGroup
|
|
{
|
|
ChanceOfOccur = 1.0f,
|
|
};
|
|
|
|
var time = new SkyTimeOfDay
|
|
{
|
|
Begin = 0.5f,
|
|
DirBright = dirBright,
|
|
DirHeading = 180f,
|
|
DirPitch = 70f,
|
|
DirColor = new ColorARGB { Blue = 0, Green = 0, Red = rBgrOrder, Alpha = 255 },
|
|
AmbBright = 0.4f,
|
|
AmbColor = new ColorARGB { Blue = 100, Green = 100, Red = 100, Alpha = 255 },
|
|
MinWorldFog = 120f,
|
|
MaxWorldFog = 400f,
|
|
WorldFogColor = new ColorARGB { Blue = 50, Green = 50, Red = 50, Alpha = 255 },
|
|
WorldFog = 1, // Linear
|
|
};
|
|
|
|
dg.SkyTime.Add(time);
|
|
sky.DayGroups.Add(dg);
|
|
region.SkyInfo = sky;
|
|
|
|
return region;
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadFromRegion_ConvertsFogFields()
|
|
{
|
|
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
|
|
var loaded = SkyDescLoader.LoadFromRegion(region);
|
|
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(1.0, loaded!.TickSize);
|
|
Assert.Single(loaded.DayGroups);
|
|
var grp = loaded.DayGroups[0];
|
|
Assert.Single(grp.SkyTimes);
|
|
|
|
var kf = grp.SkyTimes[0].Keyframe;
|
|
Assert.Equal(120f, kf.FogStart);
|
|
Assert.Equal(400f, kf.FogEnd);
|
|
Assert.Equal(FogMode.Linear, kf.FogMode);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness()
|
|
{
|
|
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
|
|
var loaded = SkyDescLoader.LoadFromRegion(region);
|
|
Assert.NotNull(loaded);
|
|
|
|
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
|
|
// R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176
|
|
Assert.InRange(kf.SunColor.X, 1.17f, 1.19f);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadFromRegion_NoSkyInfo_ReturnsNull()
|
|
{
|
|
var region = new Region { PartsMask = 0 };
|
|
Assert.Null(SkyDescLoader.LoadFromRegion(region));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildDefaultProvider_FromDatKeyframes_SupportsInterpolation()
|
|
{
|
|
var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
|
|
var loaded = SkyDescLoader.LoadFromRegion(region)!;
|
|
var provider = loaded.BuildDefaultProvider();
|
|
|
|
// Exactly one keyframe: interpolation at any t returns it.
|
|
var s = provider.Interpolate(0.1f);
|
|
Assert.InRange(s.SunColor.X, 0.99f, 1.01f);
|
|
}
|
|
|
|
[Fact]
|
|
public void SkyObjectData_IsVisible_HandlesWrap()
|
|
{
|
|
var obj = new SkyObjectData
|
|
{
|
|
BeginTime = 0.9f, // wraps across midnight
|
|
EndTime = 0.1f,
|
|
};
|
|
|
|
Assert.True(obj.IsVisible(0.95f)); // near end of day
|
|
Assert.True(obj.IsVisible(0.05f)); // just after midnight
|
|
Assert.False(obj.IsVisible(0.5f)); // mid-day (not visible)
|
|
}
|
|
|
|
[Fact]
|
|
public void SkyObjectData_CurrentAngle_LerpsAcrossWindow()
|
|
{
|
|
var obj = new SkyObjectData
|
|
{
|
|
BeginTime = 0.25f,
|
|
EndTime = 0.75f,
|
|
BeginAngle = 0f,
|
|
EndAngle = 180f,
|
|
};
|
|
|
|
// Middle of the window → 90°.
|
|
Assert.Equal(90f, obj.CurrentAngle(0.5f), precision: 2);
|
|
// At begin → begin angle.
|
|
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
|
|
}
|
|
}
|