using System;
namespace AcDream.Core.World;
///
/// Asheron's Call "Portal Year" calendar + time-of-day math — faithful
/// port of retail's DerethDateTime (r12 §1.2 + ACE
/// DerethDateTime.cs).
///
///
/// 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:
///
/// -
/// Day fraction in [0, 1): where we are in the 16-hour Derethian day.
///
/// -
/// Hour name (Darktide, Dawnsong, Midsong, Warmtide, Evensong,
/// Gloaming and their "-and-Half" mid-hour variants).
///
/// -
/// Day / night flag for spawn rules + lighting.
///
/// -
/// Season / year / month for calendar UI.
///
///
///
///
///
/// Constants are retail-exact; changing them breaks the calendar panel's
/// "today is" display.
///
///
public static class DerethDateTime
{
public const int HoursInADay = 16;
public const int DaysInAMonth = 30;
public const int MonthsInAYear = 12;
/// Ticks per Derethian day (matches retail GameTime.DayLength).
public const double DayTicks = 7620.0;
/// Ticks per Derethian hour (DayTicks / 16).
public const double HourTicks = DayTicks / HoursInADay; // 476.25
/// Ticks per Derethian month.
public const double MonthTicks = DayTicks * DaysInAMonth; // 228,600
/// Ticks per Derethian year.
public const double YearTicks = MonthTicks * MonthsInAYear; // 2,743,200
///
/// Above this tick count the retail client crashes on connect.
/// ~1.07 billion seconds = PY 401 Thistledown 2 Morntide-and-Half.
///
public const double MaxTicks = 1_073_741_828.0;
///
/// Base/anchor year for the Portal Year calendar (retail
/// GameTime.ZeroYear = 10). Tick 0 corresponds to PY 10,
/// Morningthaw 1, Morntide-and-Half. Retail's TimeOfDay+0x64
/// ("absolute year") includes this offset, and SkyDesc::PickCurrentDayGroup
/// (FUN_00501990) feeds the absolute year into its LCG seed.
///
public const int ZeroYear = 10;
///
/// The 16 named hour slots (r12 §1.2). Each one is half a Derethian
/// hour; the "-and-Half" variants are the second half.
///
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,
}
///
/// 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
{
Morningthaw = 0,
Solclaim,
Seedsow,
Leafdawning,
Verdantine,
Thistledown,
Harvestgain,
Leafcull,
Frostfell,
Snowreap,
Coldeve,
Wintersebb,
}
///
/// 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. 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;
///
/// 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. Anchored by
/// (dat's ZeroTimeOfYear).
///
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;
}
///
/// Hour name for the given tick count. Picked from
/// based on which named half-hour slot the
/// current time falls in.
///
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;
}
///
/// True when the current time is "day" (Dawnsong through
/// Warmtide-and-Half, hours 5–12 inclusive on the 16-hour scale).
///
public static bool IsDaytime(double ticks)
{
int h = (int)CurrentHour(ticks);
return h >= (int)HourName.Dawnsong && h <= (int)HourName.WarmtideAndHalf;
}
///
/// Derethian calendar breakdown: (year, month, day, hour).
/// 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);
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));
}
///
/// Year offset from tick-0 (number of Derethian years elapsed since
/// 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;
double shifted = ticks + OriginOffsetTicks;
return (int)(shifted / YearTicks);
}
///
/// Absolute Portal Year ( + relative year).
/// Matches retail's TimeOfDay + 0x64 field
/// (FUN_005a7510:5300:
/// floor((worldTime+base)/secsPerYear) + baseYear). This is
/// the value the retail DayGroup picker (FUN_00501990) feeds
/// into its LCG seed, so acdream must match for identical weather
/// picks vs retail.
///
public static int AbsoluteYear(double ticks) => Year(ticks) + ZeroYear;
///
/// Day index within the current Derethian year in [0, 359] (360 days
/// 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 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;
}
}