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; } }