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