diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs new file mode 100644 index 0000000..1d94303 --- /dev/null +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -0,0 +1,155 @@ +using System; + +namespace AcDream.Core.World; + +/// +/// Asheron's Call "Portal Year" calendar + time-of-day math — faithful +/// port of retail's DerethDateTime (r12 §1.2 + ACE +/// DerethDateTime.cs). +/// +/// +/// The server transports an absolute tick count as a double (seconds +/// since a seed chosen at boot). The client doesn't need to know which +/// seed was chosen — it just needs the tick count to derive: +/// +/// +/// Day fraction in [0, 1): where we are in the 16-hour Derethian day. +/// +/// +/// Hour name (Darktide, Dawnsong, Midsong, Warmtide, Evensong, +/// Gloaming and their "-and-Half" mid-hour variants). +/// +/// +/// Day / night flag for spawn rules + lighting. +/// +/// +/// Season / year / month for calendar UI. +/// +/// +/// +/// +/// +/// Constants are retail-exact; changing them breaks the calendar panel's +/// "today is" display. +/// +/// +public static class DerethDateTime +{ + public const int HoursInADay = 16; + public const int DaysInAMonth = 30; + public const int MonthsInAYear = 12; + + /// Ticks per Derethian day (matches retail GameTime.DayLength). + public const double DayTicks = 7620.0; + + /// Ticks per Derethian hour (DayTicks / 16). + public const double HourTicks = DayTicks / HoursInADay; // 476.25 + + /// Ticks per Derethian month. + public const double MonthTicks = DayTicks * DaysInAMonth; // 228,600 + + /// Ticks per Derethian year. + public const double YearTicks = MonthTicks * MonthsInAYear; // 2,743,200 + + /// + /// Above this tick count the retail client crashes on connect. + /// ~1.07 billion seconds = PY 401 Thistledown 2 Morntide-and-Half. + /// + public const double MaxTicks = 1_073_741_828.0; + + /// + /// The 16 named hour slots (r12 §1.2). Each one is half a Derethian + /// hour; the "-and-Half" variants are the second half. + /// + public enum HourName + { + Darktide = 0, + DarktideAndHalf, + Foredawn, + ForedawnAndHalf, + Dawnsong, // day starts here (hour 5) + DawnsongAndHalf, + Morntide, + MorntideAndHalf, + Midsong, + MidsongAndHalf, + Warmtide, + WarmtideAndHalf, // day ends here (hour 12) + Evensong, + EvensongAndHalf, + Gloaming, + GloamingAndHalf, + } + + /// Derethian months (Snowreap..Frostfell, 12 total). + public enum MonthName + { + Snowreap = 0, + ColdMeet, + Leafdawning, + Seedsow, + Rosetide, + Solclaim, + Thistledown, + Harvestgain, + Leaftrue, + Reaptide, + Morningthaw, + Frostfell, + } + + /// + /// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 = + /// Midsong-and-Half (noon-ish), 1.0 wraps to 0. + /// + public static double DayFraction(double ticks) + { + if (ticks < 0) ticks = 0; + double rem = ticks - Math.Floor(ticks / DayTicks) * DayTicks; + return rem / DayTicks; + } + + /// + /// Hour name for the given tick count. Picked from + /// based on which named half-hour slot the + /// current time falls in. + /// + public static HourName CurrentHour(double ticks) + { + double f = DayFraction(ticks); + int slot = (int)Math.Floor(f * HoursInADay); + if (slot < 0) slot = 0; + if (slot > 15) slot = 15; + return (HourName)slot; + } + + /// + /// True when the current time is "day" (Dawnsong through + /// Warmtide-and-Half, hours 5–12 inclusive on the 16-hour scale). + /// + public static bool IsDaytime(double ticks) + { + int h = (int)CurrentHour(ticks); + return h >= (int)HourName.Dawnsong && h <= (int)HourName.WarmtideAndHalf; + } + + /// + /// Derethian calendar breakdown: (year, month, day, hour). + /// Year starts at PY 0. Day is 1-based within the month (1..30). + /// + public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour); + + public static Calendar ToCalendar(double ticks) + { + if (ticks < 0) ticks = 0; + int year = (int)(ticks / YearTicks); + double tYear = ticks - year * YearTicks; + int monthIdx = (int)(tYear / MonthTicks); + if (monthIdx > 11) monthIdx = 11; + double tMonth = tYear - monthIdx * MonthTicks; + int day = (int)(tMonth / DayTicks) + 1; + if (day > DaysInAMonth) day = DaysInAMonth; + + return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks)); + } +} diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs new file mode 100644 index 0000000..e278f53 --- /dev/null +++ b/src/AcDream.Core/World/SkyState.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.World; + +/// +/// One sky keyframe — the lighting + fog state for a specific day-fraction. +/// Multiple keyframes across [0, 1) interpolate linearly (with angular +/// wrap on sun direction) to produce the current sky state. +/// +/// +/// Retail's SkyTimeOfDay dat struct carries this exact data plus +/// references to sky objects (sun mesh, moon mesh, cloud layer) which +/// belong to the renderer. This class exposes the lighting-relevant +/// subset — sun direction, sun color, ambient color, fog. +/// +/// +public readonly record struct SkyKeyframe( + float Begin, // [0, 1] day-fraction this keyframe kicks in + float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W) + float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith) + Vector3 SunColor, // RGB linear, post-brightness multiply + Vector3 AmbientColor, + Vector3 FogColor, + float FogDensity); + +/// +/// Sky keyframe interpolator — given a day fraction in [0, 1), returns +/// the blended lighting state between the surrounding keyframes. +/// +/// +/// Math (r12 §4): +/// +/// +/// Pick the two keyframes bracketing t: k1 = last +/// keyframe with Begin <= t, k2 = next keyframe +/// (wraps: if k1 is last, k2 is first). +/// +/// +/// Local blend u = (t - k1.Begin) / (k2.Begin - k1.Begin) +/// with wrap handling. +/// +/// +/// Lerp every vector component; SLERP the sun direction +/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading +/// = 350°, k2.Heading = 10°). +/// +/// +/// +/// +public sealed class SkyStateProvider +{ + private readonly List _keyframes; + + public SkyStateProvider(IReadOnlyList keyframes) + { + if (keyframes is null || keyframes.Count == 0) + throw new ArgumentException("At least one keyframe required", nameof(keyframes)); + // Sort by Begin so the walk is deterministic regardless of input order. + var sorted = new List(keyframes); + sorted.Sort((a, b) => a.Begin.CompareTo(b.Begin)); + _keyframes = sorted; + } + + public int KeyframeCount => _keyframes.Count; + + /// + /// Default keyframe set based on retail observations — sunrise at 6am, + /// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't + /// available yet or the player is in a region whose Region dat + /// doesn't override it. + /// + public static SkyStateProvider Default() + { + // Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk. + return new SkyStateProvider(new[] + { + new SkyKeyframe( + Begin: 0.0f, + SunHeadingDeg: 0f, // below horizon (north) + SunPitchDeg: -30f, + SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue + AmbientColor: new Vector3(0.05f, 0.05f, 0.12f), + FogColor: new Vector3(0.02f, 0.02f, 0.05f), + FogDensity: 0.004f), + new SkyKeyframe( + Begin: 0.25f, + SunHeadingDeg: 90f, // east at dawn + SunPitchDeg: 0f, + SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm + AmbientColor: new Vector3(0.4f, 0.35f, 0.3f), + FogColor: new Vector3(0.8f, 0.55f, 0.4f), + FogDensity: 0.002f), + new SkyKeyframe( + Begin: 0.5f, + SunHeadingDeg: 180f, // south at noon + SunPitchDeg: 70f, + SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish + AmbientColor: new Vector3(0.5f, 0.5f, 0.55f), + FogColor: new Vector3(0.7f, 0.75f, 0.85f), + FogDensity: 0.0008f), + new SkyKeyframe( + Begin: 0.75f, + SunHeadingDeg: 270f, // west at dusk + SunPitchDeg: 0f, + SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red + AmbientColor: new Vector3(0.35f, 0.25f, 0.25f), + FogColor: new Vector3(0.85f, 0.45f, 0.35f), + FogDensity: 0.002f), + }); + } + + /// + /// Current interpolated sky state at day fraction . + /// Wraps correctly across the day boundary (1.0 → 0.0). + /// + public SkyKeyframe Interpolate(float t) + { + t = (float)(t - Math.Floor(t)); // wrap to [0, 1) + + // Find k1: last keyframe with Begin <= t. + int k1Index = _keyframes.Count - 1; + for (int i = 0; i < _keyframes.Count; i++) + { + if (_keyframes[i].Begin <= t) + k1Index = i; + else + break; + } + int k2Index = (k1Index + 1) % _keyframes.Count; + + var k1 = _keyframes[k1Index]; + var k2 = _keyframes[k2Index]; + + // Compute blend weight, handling wrap (k1 is last, k2 is first). + float k1Begin = k1.Begin; + float k2Begin = k2.Begin; + if (k2Begin <= k1Begin) k2Begin += 1.0f; // unroll wrap + float tWrapped = t; + if (tWrapped < k1Begin) tWrapped += 1.0f; + + float span = Math.Max(1e-6f, k2Begin - k1Begin); + float u = (tWrapped - k1Begin) / span; + u = Math.Clamp(u, 0f, 1f); + + // Angular lerp for sun heading: pick shortest arc. + float h1 = k1.SunHeadingDeg; + float h2 = k2.SunHeadingDeg; + float delta = h2 - h1; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + float heading = h1 + delta * u; + + return new SkyKeyframe( + Begin: t, + SunHeadingDeg: heading, + SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u, + SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u), + AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u), + FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u), + FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * u); + } + + /// + /// World-space sun direction unit vector pointing FROM the surface + /// TOWARDS the sun. Derived from heading + pitch in the returned + /// keyframe — shader sunDir uniform should use -this so lighting + /// math (N·L) works correctly for the side facing the sun. + /// + public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf) + { + float yaw = kf.SunHeadingDeg * (MathF.PI / 180f); + float pit = kf.SunPitchDeg * (MathF.PI / 180f); + // Heading 0 = +Y (north), +X=east. Pitch up from horizon. + float cosP = MathF.Cos(pit); + return new Vector3( + MathF.Sin(yaw) * cosP, + MathF.Cos(yaw) * cosP, + MathF.Sin(pit)); + } +} + +/// +/// Service that turns server-delivered tick counts into live sky state. +/// Owns the "current time" clock (seeded from server sync, advanced by +/// real-time elapsed between syncs). +/// +public sealed class WorldTimeService +{ + private readonly SkyStateProvider _sky; + private double _lastSyncedTicks; + private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow; + + public WorldTimeService(SkyStateProvider sky) + { + _sky = sky ?? throw new ArgumentNullException(nameof(sky)); + } + + /// + /// Set the authoritative tick count from a server TimeSync packet. + /// + public void SyncFromServer(double serverTicks) + { + _lastSyncedTicks = serverTicks; + _lastSyncedWallClockUtc = DateTime.UtcNow; + } + + /// + /// Current ticks at , advanced from the + /// last sync by real-time elapsed seconds. + /// + public double NowTicks + { + get + { + double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds; + return _lastSyncedTicks + elapsed; + } + } + + /// Current day fraction in [0, 1). + public double DayFraction => DerethDateTime.DayFraction(NowTicks); + + /// Current sky lighting state. + public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction); + + /// Convenience: current sun direction from derived sky state. + public Vector3 CurrentSunDirection => + SkyStateProvider.SunDirectionFromKeyframe(CurrentSky); + + public DerethDateTime.Calendar CurrentCalendar => + DerethDateTime.ToCalendar(NowTicks); + + public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks); +} diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs new file mode 100644 index 0000000..09ec90a --- /dev/null +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs @@ -0,0 +1,86 @@ +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.World; + +public sealed class DerethDateTimeTests +{ + [Fact] + public void DayFraction_AtMidnight_IsZero() + { + Assert.Equal(0.0, DerethDateTime.DayFraction(0)); + } + + [Fact] + public void DayFraction_AtHalfDay_IsHalf() + { + Assert.Equal(0.5, DerethDateTime.DayFraction(DerethDateTime.DayTicks / 2), 4); + } + + [Fact] + public void DayFraction_WrapsAfterOneDay() + { + Assert.Equal(0.0, DerethDateTime.DayFraction(DerethDateTime.DayTicks), 6); + Assert.Equal(0.25, DerethDateTime.DayFraction(DerethDateTime.DayTicks * 1.25), 4); + } + + [Fact] + public void CurrentHour_AtMidnight_IsDarktide() + { + Assert.Equal(DerethDateTime.HourName.Darktide, DerethDateTime.CurrentHour(0)); + } + + [Fact] + public void CurrentHour_AtDawn_IsDawnsong() + { + // Dawnsong is the 5th slot (0-indexed slot 4) — day-fraction 4/16 = 0.25. + double ticks = DerethDateTime.DayTicks * (4.0 / 16.0); + Assert.Equal(DerethDateTime.HourName.Dawnsong, DerethDateTime.CurrentHour(ticks)); + } + + [Fact] + public void CurrentHour_AtNoon_IsMidsong() + { + // Midsong is 0-indexed slot 8 → day-fraction 0.5. + double ticks = DerethDateTime.DayTicks * 0.5; + Assert.Equal(DerethDateTime.HourName.Midsong, DerethDateTime.CurrentHour(ticks)); + } + + [Fact] + public void IsDaytime_Dawn_True() + { + double ticks = DerethDateTime.DayTicks * (4.0 / 16.0); // Dawnsong start + Assert.True(DerethDateTime.IsDaytime(ticks)); + } + + [Fact] + public void IsDaytime_Night_False() + { + double ticks = DerethDateTime.DayTicks * (1.0 / 16.0); // Darktide-and-Half + Assert.False(DerethDateTime.IsDaytime(ticks)); + } + + [Fact] + public void ToCalendar_PY0Day1_Snowreap() + { + var cal = DerethDateTime.ToCalendar(0); + Assert.Equal(0, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(1, cal.Day); + } + + [Fact] + public void ToCalendar_AdvancesCorrectly() + { + // One year from start → PY 1, Snowreap 1. + var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks); + Assert.Equal(1, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(1, cal.Day); + + // One month into year 1. + var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks); + Assert.Equal(1, cal2.Year); + Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month); + } +} diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs new file mode 100644 index 0000000..74a9127 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -0,0 +1,89 @@ +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, + SunColor: Vector3.One, + AmbientColor: Vector3.One, + 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()); + // Sync to exactly noon of day 0. + service.SyncFromServer(DerethDateTime.DayTicks * 0.5); + + Assert.InRange(service.DayFraction, 0.499, 0.501); + Assert.True(service.IsDaytime); + } +}