diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d2a63aa..d8230c5 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -893,6 +893,26 @@ public sealed class GameWindow : IDisposable
// 2026-04-23).
WorldTime.TickSize = 1.0;
+ // Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the
+ // calendar-extraction offset. Dereth's dat value is 3600
+ // (verified 2026-04-23 live dump); ACE's DerethDateTime.cs
+ // comment that "tick 0 = Morntide-and-Half" (3333.75
+ // offset = +7/16) is WRONG by 266.25 ticks against the
+ // authoritative dat. The mismatch cascaded into both the
+ // wrong hour label AND the wrong DayOfYear at boundary
+ // times (different LCG seed → different DayGroup roll),
+ // which explained the user's observation of "acdream
+ // clear night, retail stormy pre-dawn" at the same
+ // server PortalYearTicks.
+ if (region.GameTime is not null)
+ {
+ AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat(
+ region.GameTime.ZeroTimeOfYear);
+ Console.WriteLine(
+ $"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " +
+ $"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})");
+ }
+
Console.WriteLine(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs
index 0ee9c2d..592b868 100644
--- a/src/AcDream.Core/World/DerethDateTime.cs
+++ b/src/AcDream.Core/World/DerethDateTime.cs
@@ -108,36 +108,54 @@ public static class DerethDateTime
}
///
- /// In ACE's calendar, tick 0 is defined as "Morningthaw 1,
- /// 10 P.Y. — Morntide-and-Half", NOT midnight
- /// (references/ACE/Source/ACE.Common/DerethDateTime.cs:23,
- /// private const double dayZeroTicks = 0; // Morntide-and-Half
- /// + private int hour = (int)Hours.Morntide_and_Half;).
- ///
- ///
- /// Morntide-and-Half is slot 7 on the 16-slot (0-indexed) scale,
- /// i.e. late morning just before noon. Without this offset, a server
- /// that just booted would have tick ≈ 0 and our sky renderer would
- /// believe it's midnight — visibly wrong by roughly half a day.
- /// Applying +7/16 of a full day (3333.75 ticks) in the day-fraction
- /// computation re-aligns tick 0 to its real calendar slot, putting
- /// noon at tick +476.25 / 2 (approximately) and Darktide at
- /// tick +4286.25, which matches ACE's
- /// dayOneTicks = 0 + 210 + (hourTicks * 8) // Morningthaw 2, Darktide.
- ///
+ /// Default anchor ticks when no Region has been loaded: +7/16 of a
+ /// full day (3333.75) per ACE's DerethDateTime comment "tick 0 =
+ /// Morntide-and-Half". This is a fallback — retail reads
+ /// GameTime.ZeroTimeOfYear from the Region dat (verified
+ /// 3600 in Dereth, 2026-04-23 live dump) and uses that as
+ /// the additive offset. We override
+ /// once the dat loads; this constant is only for offline tests.
///
public const double DayFractionOriginOffsetTicks = (7.0 / 16.0) * DayTicks; // 3333.75
+ ///
+ /// Additive tick offset applied before every calendar extraction
+ /// (DayFraction / Year / DayOfYear / AbsoluteYear). Populated from
+ /// the Region dat's GameTime.ZeroTimeOfYear via
+ /// at Region load. Defaults to
+ /// for offline tests and
+ /// for the boot window before the dat parses.
+ ///
+ ///
+ /// Live Dereth dat value: 3600. The +7/16 default is wrong
+ /// by 266.25 ticks (~33 Derethian minutes) and was the source of
+ /// the "acdream time is behind retail" + "wrong DayGroup picked"
+ /// observations in the 2026-04-23 live verification session — see
+ /// docs/research/2026-04-23-daygroup-selection.md §4 and
+ /// the Phase 3f commit.
+ ///
+ ///
+ public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
+
+ ///
+ /// Adopt the Region dat's GameTime.ZeroTimeOfYear as the
+ /// calendar-extraction offset. Idempotent; safe to call on every
+ /// Region reload (though in practice Dereth is the only region).
+ ///
+ public static void SetOriginOffsetFromDat(double zeroTimeOfYear)
+ {
+ OriginOffsetTicks = zeroTimeOfYear;
+ }
+
///
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
- /// Midsong-and-Half (noon-ish), 1.0 wraps to 0. Corrected for the
- /// ACE tick-0 = Morntide-and-Half convention (see
- /// ).
+ /// Midsong-and-Half (noon-ish), 1.0 wraps to 0. Anchored by
+ /// (dat's ZeroTimeOfYear).
///
public static double DayFraction(double ticks)
{
if (ticks < 0) ticks = 0;
- double shifted = ticks + DayFractionOriginOffsetTicks;
+ double shifted = ticks + OriginOffsetTicks;
double rem = shifted - Math.Floor(shifted / DayTicks) * DayTicks;
return rem / DayTicks;
}
@@ -175,8 +193,9 @@ public static class DerethDateTime
public static Calendar ToCalendar(double ticks)
{
if (ticks < 0) ticks = 0;
- int year = (int)(ticks / YearTicks);
- double tYear = ticks - year * YearTicks;
+ double shifted = ticks + OriginOffsetTicks;
+ int year = (int)(shifted / YearTicks);
+ double tYear = shifted - year * YearTicks;
int monthIdx = (int)(tYear / MonthTicks);
if (monthIdx > 11) monthIdx = 11;
double tMonth = tYear - monthIdx * MonthTicks;
@@ -188,15 +207,17 @@ public static class DerethDateTime
///
/// Year offset from tick-0 (number of Derethian years elapsed since
- /// the PY anchor). Use for time-delta math.
- /// For calendar display and for the retail
- /// SkyDesc::PickCurrentDayGroup LCG seed, use
+ /// the PY anchor). Shifted by
+ /// (dat's ZeroTimeOfYear) to match
+ /// retail's TimeOfDay::OnTick extraction. Use for time-delta math.
+ /// For calendar display and for the retail DayGroup LCG seed, use
/// which adds the ZeroYear base.
///
public static int Year(double ticks)
{
if (ticks < 0) ticks = 0;
- return (int)(ticks / YearTicks);
+ double shifted = ticks + OriginOffsetTicks;
+ return (int)(shifted / YearTicks);
}
///
@@ -212,14 +233,18 @@ public static class DerethDateTime
///
/// Day index within the current Derethian year in [0, 359] (360 days
- /// per year = 12 months × 30). Matches retail's TimeOfDay + 0x68
- /// field which is floor(withinYearSec / secsPerDay). Consumed
- /// by SkyDesc.PickCurrentDayGroup as part of the per-day seed.
+ /// per year = 12 months × 30). Shifted by
+ /// (dat's ZeroTimeOfYear) to match retail's TimeOfDay + 0x68
+ /// field (FUN_005a7510:5304:
+ /// floor(withinYearSec / secsPerDay)). Consumed by
+ /// SkyDesc.PickCurrentDayGroup as part of the per-day seed.
///
public static int DayOfYear(double ticks)
{
if (ticks < 0) ticks = 0;
- double tYear = ticks - Year(ticks) * YearTicks;
+ double shifted = ticks + OriginOffsetTicks;
+ int year = (int)(shifted / YearTicks);
+ double tYear = shifted - year * YearTicks;
int d = (int)(tYear / DayTicks);
if (d < 0) d = 0;
if (d > 359) d = 359;
diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs
index fde70fc..a75edab 100644
--- a/src/AcDream.Core/World/SkyDescLoader.cs
+++ b/src/AcDream.Core/World/SkyDescLoader.cs
@@ -393,6 +393,40 @@ public static class SkyDescLoader
Console.WriteLine("[sky-dump] ======== BEGIN SkyDesc dump ========");
Console.WriteLine($"[sky-dump] Region Id={region.Id:X8} Number={region.RegionNumber} Name=\"{region.RegionName}\"");
+
+ // Phase 3f diag — retail TimeOfDay::OnTick uses
+ // GameTime.ZeroTimeOfYear as an additive tick offset before
+ // calendar extraction. Our DerethDateTime currently hardcodes
+ // +7/16 (= 3333.75) for the dayFraction math and IGNORES this
+ // field. If the dat value is anything other than what we assume,
+ // our calendar is skewed from retail's at every moment.
+ var gt = region.GameTime;
+ if (gt is not null)
+ {
+ Console.WriteLine(
+ $"[sky-dump] GameTime ZeroTimeOfYear={gt.ZeroTimeOfYear} ZeroYear={gt.ZeroYear} " +
+ $"DayLength={gt.DayLength} DaysPerYear={gt.DaysPerYear} " +
+ $"YearSpec=\"{gt.YearSpec}\" TimesOfDay.Count={gt.TimesOfDay?.Count ?? 0} " +
+ $"DaysOfWeek.Count={gt.DaysOfWeek?.Count ?? 0} Seasons.Count={gt.Seasons?.Count ?? 0}");
+
+ // Dump every TimeOfDay slot — this is the retail-authoritative
+ // hour boundary table. Anchors our offset math: if retail
+ // TimeOfDay[0].Start != our assumed 0, we have our answer.
+ if (gt.TimesOfDay is not null)
+ {
+ for (int i = 0; i < gt.TimesOfDay.Count; i++)
+ {
+ var t = gt.TimesOfDay[i];
+ Console.WriteLine(
+ $"[sky-dump] TimeOfDay[{i}] Start={t.Start} IsNight={t.IsNight} Name=\"{t.Name}\"");
+ }
+ }
+ }
+ else
+ {
+ Console.WriteLine("[sky-dump] GameTime: null (no calendar info in this region)");
+ }
+
Console.WriteLine($"[sky-dump] SkyDesc TickSize={sky.TickSize} LightTickSize={sky.LightTickSize} DayGroups.Count={sky.DayGroups.Count}");
for (int g = 0; g < sky.DayGroups.Count; g++)