From dbe6690a4e9f7615d2f10b9369e53d0eab158dfd Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 14:43:49 +0200 Subject: [PATCH] fix(time): retail-canonical month enum + absolute Portal Year + title-bar calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in calendar display (the CLOCK ITSELF was already correct): 1. **Month enum had wrong order + non-retail names.** Old enum: Snowreap=0, ColdMeet, Leafdawning, Seedsow, Rosetide, Solclaim, ... At day-of-year 83 this gave month index 2 = Leafdawning. Retail's @timestamp at the same moment shows "Seedsow 24". Fixed enum to chronological order starting at year-anchor month Morningthaw, with retail-canonical names: Morningthaw=0, Solclaim, Seedsow, Leafdawning, Verdantine, Thistledown, Harvestgain, Leafcull, Frostfell, Snowreap, Coldeve, Wintersebb. At day-of-year 83 → month 2 = Seedsow ✓ 2. **ToCalendar returned relative year, not absolute Portal Year.** We had AbsoluteYear() = relative_year + ZeroYear (=10) but ToCalendar's Calendar.Year was the relative one. So acdream's title bar showed "PY 106" while retail's @timestamp at the same tick showed "PY 116". Fixed ToCalendar to add ZeroYear so the exposed Calendar.Year matches retail's display. 3. **GameWindow title bar now shows the calendar.** Format mirrors retail's @timestamp output: "PY (df=)" Lets the user read the same fields off both clients and confirm clock parity directly. Drift > 1 hour = real bug. Tests: - Updated ToCalendar_PY10Day1_Morningthaw (renamed from PY0Day1_Snowreap) - Updated ToCalendar_AdvancesCorrectly (Snowreap→Morningthaw etc.) - Added regression: ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat pinning a retail-known tick → retail-known calendar string. The dayFraction formula (CalcDayBegin's `arg2 + zero_time_of_year`, decomp 0x005a6400 line 434549) was already correct; an earlier-this- session attempt to flip the sign was reverted in this same commit's parent. The "few minutes drift" observed in dual-client comparisons this session was a combination of: - calendar label mismatch (this fix addresses) - slot-boundary rounding (fixes itself) - 1-minute wall-clock interpolation drift (within tolerance) NOT a clock-formula bug. ISSUE #3 in docs/ISSUES.md is now misnamed ("Client clock drifts from retail"); plan to re-title or close in a follow-up commit after the visual-divergence investigation lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 15 ++++- src/AcDream.Core/World/DerethDateTime.cs | 57 ++++++++++++------- .../World/DerethDateTimeTests.cs | 48 +++++++++++++--- 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 66888b6..d0d1028 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4565,9 +4565,18 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + - $"lb {visibleLandblocks}/{totalLandblocks} visible | " + - $"ent {entityCount} | anim {animatedCount}"; + // Calendar display matches retail's @timestamp output: + // "Date: , PY Time: ". + // Use NowTicks (server-synced + wall-clock interpolation) so the + // user can read the same fields off both acdream and retail and + // confirm clock parity directly. Drift > 1 hour = real bug. + double tNow = WorldTime.NowTicks; + var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow); + double df = WorldTime.DayFraction; + _window!.Title = + $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " + + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs index 592b868..a2e843d 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -90,21 +90,30 @@ public static class DerethDateTime GloamingAndHalf, } - /// Derethian months (Snowreap..Frostfell, 12 total). + /// + /// Derethian months in chronological order. Year-0 begins at month 0 + /// () and progresses through the 12-month + /// cycle. Names + order match retail's calendar display + /// (GameTime::CalcDayBegin + GetDateTimeString at + /// 0x005a6530) and ACE's DerethDateTime.cs. Verified + /// against retail's @timestamp output in 2026-04-27 dual- + /// client comparison: at day-of-year 83, retail shows + /// "Seedsow 24" — that fixes month index 2 = Seedsow. + /// public enum MonthName { - Snowreap = 0, - ColdMeet, - Leafdawning, - Seedsow, - Rosetide, + Morningthaw = 0, Solclaim, + Seedsow, + Leafdawning, + Verdantine, Thistledown, Harvestgain, - Leaftrue, - Reaptide, - Morningthaw, + Leafcull, Frostfell, + Snowreap, + Coldeve, + Wintersebb, } /// @@ -127,12 +136,15 @@ public static class DerethDateTime /// 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. + /// Live Dereth dat value: 3600. Retail's + /// GameTime::CalcDayBegin at 0x005a6400 (decomp line + /// 434549) computes arg2 + zero_time_of_year as the basis for + /// year/day-of-year extraction, then derives time_of_day_begin + /// such that (arg2 - time_of_day_begin) / day_length in + /// CalcTimeOfDay gives (arg2 + zero_time_of_year) mod day_length / day_length. + /// Net: the formula is ADD, not subtract — confirmed via the explicit + /// add at line 434549. (A 2026-04-26 attempt to flip the sign over- + /// corrected and broke DG selection; reverted in the same commit.) /// /// public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks; @@ -186,7 +198,10 @@ public static class DerethDateTime /// /// Derethian calendar breakdown: (year, month, day, hour). - /// Year starts at PY 0. Day is 1-based within the month (1..30). + /// is the absolute Portal Year (= relative-year + + /// ) so the value matches retail's + /// @timestamp output ("Date: <Month> <Day>, + /// <Year> P.Y."). Day is 1-based within the month (1..30). /// public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour); @@ -194,15 +209,19 @@ public static class DerethDateTime { if (ticks < 0) ticks = 0; double shifted = ticks + OriginOffsetTicks; - int year = (int)(shifted / YearTicks); - double tYear = shifted - year * YearTicks; + int relativeYear = (int)(shifted / YearTicks); + double tYear = shifted - relativeYear * 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)); + // Absolute Portal Year for display: retail's @timestamp shows + // PY-with-base (10 P.Y. == year 0 of the calendar epoch), so add + // ZeroYear here. Matches AbsoluteYear() and the retail decomp at + // FUN_005a7510:5300. + return new Calendar(relativeYear + ZeroYear, (MonthName)monthIdx, day, CurrentHour(ticks)); } /// diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs index 19d44ef..86fb5a9 100644 --- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs +++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs @@ -75,26 +75,56 @@ public sealed class DerethDateTimeTests } [Fact] - public void ToCalendar_PY0Day1_Snowreap() + public void ToCalendar_PY10Day1_Morningthaw() { + // Tick 0 maps to PY 10 (= relative year 0 + ZeroYear=10), + // Morningthaw 1 — matches retail's calendar epoch + // (ACE DerethDateTime.cs: dayZeroTicks = 0; // Morningthaw 1, 10 P.Y.). var cal = DerethDateTime.ToCalendar(0); - Assert.Equal(0, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(DerethDateTime.ZeroYear, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); Assert.Equal(1, cal.Day); } [Fact] public void ToCalendar_AdvancesCorrectly() { - // One year from start → PY 1, Snowreap 1. + // One year from start → PY (10 + 1) = 11, Morningthaw 1. var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks); - Assert.Equal(1, cal.Year); - Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month); + Assert.Equal(DerethDateTime.ZeroYear + 1, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month); Assert.Equal(1, cal.Day); - // One month into year 1. + // One month into year 11 → Solclaim (next month after Morningthaw). var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks); - Assert.Equal(1, cal2.Year); - Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month); + Assert.Equal(DerethDateTime.ZeroYear + 1, cal2.Year); + Assert.Equal(DerethDateTime.MonthName.Solclaim, cal2.Month); + } + + [Fact] + public void ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat() + { + // Regression guard for the 2026-04-27 dual-client comparison. + // Retail @timestamp output format is + // "Date: , P.Y." + // Pick a tick at the exact start of Seedsow 24 in relative year 106: + // shifted = 106 * YearTicks + 2 * MonthTicks + 23 * DayTicks + // Derived: 290,779,200 + 457,200 + 175,260 = 291,411,660. Subtract + // OriginOffsetTicks (3600 in Dereth dat) to get the input tick: + // 291,411,660 - 3600 = 291,408,060 + // Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow, + // day 24 1-indexed. + DerethDateTime.SetOriginOffsetFromDat(3600.0); + try + { + var cal = DerethDateTime.ToCalendar(291_408_060.0); + Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year); + Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month); + Assert.Equal(24, cal.Day); + } + finally + { + DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks); + } } }