feat(world): Phase G.1 data model — dat-accurate SkyKeyframe + WeatherSystem
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>
This commit is contained in:
parent
63b6922fc2
commit
0df1c5b4a6
5 changed files with 982 additions and 31 deletions
136
tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
Normal file
136
tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue