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>
272 lines
10 KiB
C#
272 lines
10 KiB
C#
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 5–12 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: <Month> <Day>,
|
||
/// <Year> 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;
|
||
}
|
||
}
|