acdream/src/AcDream.Core/World/DerethDateTime.cs
Erik dbe6690a4e fix(time): retail-canonical month enum + absolute Portal Year + title-bar calendar
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<Year> <Month> <Day> <Hour> (df=<dayFraction>)"
   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) <noreply@anthropic.com>
2026-04-27 14:43:49 +02:00

272 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
namespace AcDream.Core.World;
/// <summary>
/// Asheron's Call "Portal Year" calendar + time-of-day math — faithful
/// port of retail's <c>DerethDateTime</c> (r12 §1.2 + ACE
/// <c>DerethDateTime.cs</c>).
///
/// <para>
/// 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:
/// <list type="bullet">
/// <item><description>
/// Day fraction in [0, 1): where we are in the 16-hour Derethian day.
/// </description></item>
/// <item><description>
/// Hour name (Darktide, Dawnsong, Midsong, Warmtide, Evensong,
/// Gloaming and their "-and-Half" mid-hour variants).
/// </description></item>
/// <item><description>
/// Day / night flag for spawn rules + lighting.
/// </description></item>
/// <item><description>
/// Season / year / month for calendar UI.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Constants are retail-exact; changing them breaks the calendar panel's
/// "today is" display.
/// </para>
/// </summary>
public static class DerethDateTime
{
public const int HoursInADay = 16;
public const int DaysInAMonth = 30;
public const int MonthsInAYear = 12;
/// <summary>Ticks per Derethian day (matches retail GameTime.DayLength).</summary>
public const double DayTicks = 7620.0;
/// <summary>Ticks per Derethian hour (DayTicks / 16).</summary>
public const double HourTicks = DayTicks / HoursInADay; // 476.25
/// <summary>Ticks per Derethian month.</summary>
public const double MonthTicks = DayTicks * DaysInAMonth; // 228,600
/// <summary>Ticks per Derethian year.</summary>
public const double YearTicks = MonthTicks * MonthsInAYear; // 2,743,200
/// <summary>
/// Above this tick count the retail client crashes on connect.
/// ~1.07 billion seconds = PY 401 Thistledown 2 Morntide-and-Half.
/// </summary>
public const double MaxTicks = 1_073_741_828.0;
/// <summary>
/// Base/anchor year for the Portal Year calendar (retail
/// <c>GameTime.ZeroYear</c> = 10). Tick 0 corresponds to PY 10,
/// Morningthaw 1, Morntide-and-Half. Retail's <c>TimeOfDay+0x64</c>
/// ("absolute year") includes this offset, and <c>SkyDesc::PickCurrentDayGroup</c>
/// (<c>FUN_00501990</c>) feeds the absolute year into its LCG seed.
/// </summary>
public const int ZeroYear = 10;
/// <summary>
/// The 16 named hour slots (r12 §1.2). Each one is half a Derethian
/// hour; the "-and-Half" variants are the second half.
/// </summary>
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,
}
/// <summary>
/// Derethian months in chronological order. Year-0 begins at month 0
/// (<see cref="Morningthaw"/>) and progresses through the 12-month
/// cycle. Names + order match retail's calendar display
/// (<c>GameTime::CalcDayBegin</c> + <c>GetDateTimeString</c> at
/// <c>0x005a6530</c>) and ACE's <c>DerethDateTime.cs</c>. Verified
/// against retail's <c>@timestamp</c> output in 2026-04-27 dual-
/// client comparison: at day-of-year 83, retail shows
/// "Seedsow 24" — that fixes month index 2 = Seedsow.
/// </summary>
public enum MonthName
{
Morningthaw = 0,
Solclaim,
Seedsow,
Leafdawning,
Verdantine,
Thistledown,
Harvestgain,
Leafcull,
Frostfell,
Snowreap,
Coldeve,
Wintersebb,
}
/// <summary>
/// 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
/// <c>GameTime.ZeroTimeOfYear</c> from the Region dat (verified
/// <c>3600</c> in Dereth, 2026-04-23 live dump) and uses that as
/// the additive offset. We override <see cref="OriginOffsetTicks"/>
/// once the dat loads; this constant is only for offline tests.
/// </summary>
public const double DayFractionOriginOffsetTicks = (7.0 / 16.0) * DayTicks; // 3333.75
/// <summary>
/// Additive tick offset applied before every calendar extraction
/// (DayFraction / Year / DayOfYear / AbsoluteYear). Populated from
/// the Region dat's <c>GameTime.ZeroTimeOfYear</c> via
/// <see cref="SetOriginOffsetFromDat"/> at Region load. Defaults to
/// <see cref="DayFractionOriginOffsetTicks"/> for offline tests and
/// for the boot window before the dat parses.
///
/// <para>
/// Live Dereth dat value: <c>3600</c>. Retail's
/// <c>GameTime::CalcDayBegin</c> at <c>0x005a6400</c> (decomp line
/// 434549) computes <c>arg2 + zero_time_of_year</c> as the basis for
/// year/day-of-year extraction, then derives <c>time_of_day_begin</c>
/// such that <c>(arg2 - time_of_day_begin) / day_length</c> in
/// <c>CalcTimeOfDay</c> gives <c>(arg2 + zero_time_of_year) mod day_length / day_length</c>.
/// 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.)
/// </para>
/// </summary>
public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
/// <summary>
/// Adopt the Region dat's <c>GameTime.ZeroTimeOfYear</c> as the
/// calendar-extraction offset. Idempotent; safe to call on every
/// Region reload (though in practice Dereth is the only region).
/// </summary>
public static void SetOriginOffsetFromDat(double zeroTimeOfYear)
{
OriginOffsetTicks = zeroTimeOfYear;
}
/// <summary>
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
/// Midsong-and-Half (noon-ish), 1.0 wraps to 0. Anchored by
/// <see cref="OriginOffsetTicks"/> (dat's ZeroTimeOfYear).
/// </summary>
public static double DayFraction(double ticks)
{
if (ticks < 0) ticks = 0;
double shifted = ticks + OriginOffsetTicks;
double rem = shifted - Math.Floor(shifted / DayTicks) * DayTicks;
return rem / DayTicks;
}
/// <summary>
/// Hour name for the given tick count. Picked from
/// <see cref="HourName"/> based on which named half-hour slot the
/// current time falls in.
/// </summary>
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;
}
/// <summary>
/// True when the current time is "day" (Dawnsong through
/// Warmtide-and-Half, hours 512 inclusive on the 16-hour scale).
/// </summary>
public static bool IsDaytime(double ticks)
{
int h = (int)CurrentHour(ticks);
return h >= (int)HourName.Dawnsong && h <= (int)HourName.WarmtideAndHalf;
}
/// <summary>
/// Derethian calendar breakdown: (year, month, day, hour).
/// <see cref="Year"/> is the absolute Portal Year (= relative-year +
/// <see cref="ZeroYear"/>) so the value matches retail's
/// <c>@timestamp</c> output ("Date: &lt;Month&gt; &lt;Day&gt;,
/// &lt;Year&gt; P.Y."). Day is 1-based within the month (1..30).
/// </summary>
public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour);
public static Calendar ToCalendar(double ticks)
{
if (ticks < 0) ticks = 0;
double shifted = ticks + OriginOffsetTicks;
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;
// 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));
}
/// <summary>
/// Year offset from tick-0 (number of Derethian years elapsed since
/// the PY <see cref="ZeroYear"/> anchor). Shifted by
/// <see cref="OriginOffsetTicks"/> (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
/// <see cref="AbsoluteYear"/> which adds the <c>ZeroYear</c> base.
/// </summary>
public static int Year(double ticks)
{
if (ticks < 0) ticks = 0;
double shifted = ticks + OriginOffsetTicks;
return (int)(shifted / YearTicks);
}
/// <summary>
/// Absolute Portal Year (<see cref="ZeroYear"/> + relative year).
/// Matches retail's <c>TimeOfDay + 0x64</c> field
/// (<c>FUN_005a7510:5300</c>:
/// <c>floor((worldTime+base)/secsPerYear) + baseYear</c>). This is
/// the value the retail DayGroup picker (<c>FUN_00501990</c>) feeds
/// into its LCG seed, so acdream must match for identical weather
/// picks vs retail.
/// </summary>
public static int AbsoluteYear(double ticks) => Year(ticks) + ZeroYear;
/// <summary>
/// Day index within the current Derethian year in [0, 359] (360 days
/// per year = 12 months × 30). Shifted by <see cref="OriginOffsetTicks"/>
/// (dat's ZeroTimeOfYear) to match retail's <c>TimeOfDay + 0x68</c>
/// field (<c>FUN_005a7510:5304</c>:
/// <c>floor(withinYearSec / secsPerDay)</c>). Consumed by
/// <c>SkyDesc.PickCurrentDayGroup</c> as part of the per-day seed.
/// </summary>
public static int DayOfYear(double ticks)
{
if (ticks < 0) ticks = 0;
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;
return d;
}
}