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);
|
||||
}
|
||||
}
|
||||
102
tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
Normal file
102
tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
public sealed class WeatherSystemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Roll_Deterministic_ForSameDayIndex()
|
||||
{
|
||||
var a = new WeatherSystem();
|
||||
var b = new WeatherSystem();
|
||||
|
||||
for (int d = 0; d < 100; d++)
|
||||
{
|
||||
a.Tick(0, d, 100f); // big dt to finish any transition
|
||||
b.Tick(0, d, 100f);
|
||||
Assert.Equal(a.Kind, b.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_WeightsDominatedByClear()
|
||||
{
|
||||
// Clear should cover ~60% of the distribution. Sample many days
|
||||
// and check the clear fraction is in a reasonable band.
|
||||
var sys = new WeatherSystem();
|
||||
int clear = 0;
|
||||
for (int d = 0; d < 1000; d++)
|
||||
{
|
||||
sys.Tick(0, d, 100f);
|
||||
if (sys.Kind == WeatherKind.Clear) clear++;
|
||||
}
|
||||
double frac = clear / 1000.0;
|
||||
Assert.InRange(frac, 0.45, 0.75);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transition_EasesAcrossTenSeconds()
|
||||
{
|
||||
// Force Storm, then Clear, sample snapshot fog distance mid-transition.
|
||||
var sys = new WeatherSystem();
|
||||
sys.ForceWeather(WeatherKind.Storm);
|
||||
sys.Tick(0, 1, 100f); // finalize
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var stormFog = sys.Snapshot(in kf);
|
||||
Assert.Equal(WeatherKind.Storm, stormFog.Kind);
|
||||
|
||||
// Snapshot should have a small fog end (storm fog is dense).
|
||||
Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironOverride_ForcesTintedFog()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
sys.Override = EnvironOverride.RedFog;
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
Assert.Equal(EnvironOverride.RedFog, snap.Override);
|
||||
// Red override means the R channel dominates.
|
||||
Assert.True(snap.FogColor.X > snap.FogColor.Y);
|
||||
Assert.True(snap.FogColor.X > snap.FogColor.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flash_DecaysOverTime()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
sys.TriggerFlash();
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var imm = sys.Snapshot(in kf);
|
||||
Assert.True(imm.LightningFlash > 0.9f);
|
||||
|
||||
// After 1 second the flash should be mostly decayed.
|
||||
sys.Tick(0, 0, 1.0f);
|
||||
var later = sys.Snapshot(in kf);
|
||||
Assert.True(later.LightningFlash < 0.1f,
|
||||
$"lightning flash didn't decay: {later.LightningFlash}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ClearKind_PassesThroughKeyframeFog()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
sys.ForceWeather(WeatherKind.Clear);
|
||||
sys.Tick(0, 0, 100f); // finish transition
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
// Clear passes the keyframe's fog color + distances through.
|
||||
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
|
||||
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue