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);
+ }
+}