Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex <codex@openai.com>
274 lines
11 KiB
C#
274 lines
11 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::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. 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>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;
|
||
}
|
||
}
|