acdream/src/AcDream.Core/World/DerethDateTime.cs

263 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::CalcPresentDayGroup @ 0x00500E10</c> feeds the absolute year
/// into its LCG seed (older-build cross-reference <c>FUN_00501990</c>).
/// </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. Production installs that value on its
/// instance <see cref="DerethCalendar"/>; this constant remains the
/// offline/static-helper default.
/// </summary>
public const double DayFractionOriginOffsetTicks = (7.0 / 16.0) * DayTicks; // 3333.75
/// <summary>
/// Compatibility origin used by the pure static helpers. Production
/// worlds use an instance <see cref="DerethCalendar"/> carrying their
/// Region DAT origin, so multiple sessions never share mutable calendar
/// state.
///
/// <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 const double OriginOffsetTicks = DayFractionOriginOffsetTicks;
/// <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>SkyDesc::CalcPresentDayGroup @ 0x00500E10</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::CalcPresentDayGroup</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;
}
}