sky(phase-3f): anchor calendar to dat's GameTime.ZeroTimeOfYear

Final piece of the retail-sync puzzle. Live Dereth dat has
GameTime.ZeroTimeOfYear = 3600 (verified 2026-04-23 diagnostic dump).
Our DerethDateTime hardcoded +7/16 × DayTicks = 3333.75, copied from
ACE's DerethDateTime.cs comment "tick 0 = Morntide-and-Half". The dat
is authoritative; ACE's comment is wrong by 266.25 ticks (~33 Dereth
minutes).

User-observed regression (2026-04-23):
  acdream: middle-of-night (Darktide), clear, DayGroup "Sunny"
  retail:  near-pre-dawn (Foredawn), thunderstorm, stormy DayGroup
  (both connected to the same ACE at PortalYearTicks=291134079)

Same server tick → different calendar extraction → the offset skewed
dayFraction AND pushed DayOfYear across a boundary at certain ticks,
feeding a different LCG seed into the DayGroup picker (FUN_00501990).
A single 266.25-tick offset error explains both the time mismatch and
the weather mismatch.

Code changes:
- DerethDateTime.OriginOffsetTicks — runtime-settable static, default
  = DayFractionOriginOffsetTicks (3333.75, the legacy fallback).
  Applied in DayFraction, Year, DayOfYear, ToCalendar.
- DerethDateTime.SetOriginOffsetFromDat(double) — called at Region
  load.
- SkyDescLoader.DumpRegionSkyDesc dumps GameTime fields (and all 16
  TimesOfDay entries) when ACDREAM_DUMP_SKY=1.
- GameWindow.LoadRegion adopts the dat's ZeroTimeOfYear after
  LoadFromRegion, logs the before/after values.

Also dumps every Dereth TimeOfDay hour-boundary (0..15) so any future
calendar weirdness has authoritative ground truth in the log.

Build + 733 tests green (no test depended on the hardcoded offset).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 10:00:54 +02:00
parent 5f9df4d620
commit cd8a37a9c8
3 changed files with 110 additions and 31 deletions

View file

@ -893,6 +893,26 @@ public sealed class GameWindow : IDisposable
// 2026-04-23). // 2026-04-23).
WorldTime.TickSize = 1.0; WorldTime.TickSize = 1.0;
// Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the
// calendar-extraction offset. Dereth's dat value is 3600
// (verified 2026-04-23 live dump); ACE's DerethDateTime.cs
// comment that "tick 0 = Morntide-and-Half" (3333.75
// offset = +7/16) is WRONG by 266.25 ticks against the
// authoritative dat. The mismatch cascaded into both the
// wrong hour label AND the wrong DayOfYear at boundary
// times (different LCG seed → different DayGroup roll),
// which explained the user's observation of "acdream
// clear night, retail stormy pre-dawn" at the same
// server PortalYearTicks.
if (region.GameTime is not null)
{
AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat(
region.GameTime.ZeroTimeOfYear);
Console.WriteLine(
$"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " +
$"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})");
}
Console.WriteLine( Console.WriteLine(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " + $"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +

View file

@ -108,36 +108,54 @@ public static class DerethDateTime
} }
/// <summary> /// <summary>
/// In ACE's calendar, <c>tick 0</c> is defined as "Morningthaw 1, /// Default anchor ticks when no Region has been loaded: +7/16 of a
/// 10 P.Y. — Morntide-and-Half", NOT midnight /// full day (3333.75) per ACE's DerethDateTime comment "tick 0 =
/// (<c>references/ACE/Source/ACE.Common/DerethDateTime.cs:23</c>, /// Morntide-and-Half". This is a fallback — retail reads
/// <c>private const double dayZeroTicks = 0; // Morntide-and-Half</c> /// <c>GameTime.ZeroTimeOfYear</c> from the Region dat (verified
/// + <c>private int hour = (int)Hours.Morntide_and_Half;</c>). /// <c>3600</c> in Dereth, 2026-04-23 live dump) and uses that as
/// /// the additive offset. We override <see cref="OriginOffsetTicks"/>
/// <para> /// once the dat loads; this constant is only for offline tests.
/// Morntide-and-Half is slot 7 on the 16-slot (0-indexed) scale,
/// i.e. late morning just before noon. Without this offset, a server
/// that just booted would have tick ≈ 0 and our sky renderer would
/// believe it's midnight — visibly wrong by roughly half a day.
/// Applying +7/16 of a full day (3333.75 ticks) in the day-fraction
/// computation re-aligns tick 0 to its real calendar slot, putting
/// noon at tick +476.25 / 2 (approximately) and Darktide at
/// tick +4286.25, which matches ACE's
/// <c>dayOneTicks = 0 + 210 + (hourTicks * 8) // Morningthaw 2, Darktide</c>.
/// </para>
/// </summary> /// </summary>
public const double DayFractionOriginOffsetTicks = (7.0 / 16.0) * DayTicks; // 3333.75 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>. The +7/16 default is wrong
/// by 266.25 ticks (~33 Derethian minutes) and was the source of
/// the "acdream time is behind retail" + "wrong DayGroup picked"
/// observations in the 2026-04-23 live verification session — see
/// <c>docs/research/2026-04-23-daygroup-selection.md</c> §4 and
/// the Phase 3f 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> /// <summary>
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 = /// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
/// Midsong-and-Half (noon-ish), 1.0 wraps to 0. Corrected for the /// Midsong-and-Half (noon-ish), 1.0 wraps to 0. Anchored by
/// ACE tick-0 = Morntide-and-Half convention (see /// <see cref="OriginOffsetTicks"/> (dat's ZeroTimeOfYear).
/// <see cref="DayFractionOriginOffsetTicks"/>).
/// </summary> /// </summary>
public static double DayFraction(double ticks) public static double DayFraction(double ticks)
{ {
if (ticks < 0) ticks = 0; if (ticks < 0) ticks = 0;
double shifted = ticks + DayFractionOriginOffsetTicks; double shifted = ticks + OriginOffsetTicks;
double rem = shifted - Math.Floor(shifted / DayTicks) * DayTicks; double rem = shifted - Math.Floor(shifted / DayTicks) * DayTicks;
return rem / DayTicks; return rem / DayTicks;
} }
@ -175,8 +193,9 @@ public static class DerethDateTime
public static Calendar ToCalendar(double ticks) public static Calendar ToCalendar(double ticks)
{ {
if (ticks < 0) ticks = 0; if (ticks < 0) ticks = 0;
int year = (int)(ticks / YearTicks); double shifted = ticks + OriginOffsetTicks;
double tYear = ticks - year * YearTicks; int year = (int)(shifted / YearTicks);
double tYear = shifted - year * YearTicks;
int monthIdx = (int)(tYear / MonthTicks); int monthIdx = (int)(tYear / MonthTicks);
if (monthIdx > 11) monthIdx = 11; if (monthIdx > 11) monthIdx = 11;
double tMonth = tYear - monthIdx * MonthTicks; double tMonth = tYear - monthIdx * MonthTicks;
@ -188,15 +207,17 @@ public static class DerethDateTime
/// <summary> /// <summary>
/// Year offset from tick-0 (number of Derethian years elapsed since /// Year offset from tick-0 (number of Derethian years elapsed since
/// the PY <see cref="ZeroYear"/> anchor). Use for time-delta math. /// the PY <see cref="ZeroYear"/> anchor). Shifted by
/// For calendar display and for the retail /// <see cref="OriginOffsetTicks"/> (dat's ZeroTimeOfYear) to match
/// <c>SkyDesc::PickCurrentDayGroup</c> LCG seed, use /// 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. /// <see cref="AbsoluteYear"/> which adds the <c>ZeroYear</c> base.
/// </summary> /// </summary>
public static int Year(double ticks) public static int Year(double ticks)
{ {
if (ticks < 0) ticks = 0; if (ticks < 0) ticks = 0;
return (int)(ticks / YearTicks); double shifted = ticks + OriginOffsetTicks;
return (int)(shifted / YearTicks);
} }
/// <summary> /// <summary>
@ -212,14 +233,18 @@ public static class DerethDateTime
/// <summary> /// <summary>
/// Day index within the current Derethian year in [0, 359] (360 days /// Day index within the current Derethian year in [0, 359] (360 days
/// per year = 12 months × 30). Matches retail's <c>TimeOfDay + 0x68</c> /// per year = 12 months × 30). Shifted by <see cref="OriginOffsetTicks"/>
/// field which is <c>floor(withinYearSec / secsPerDay)</c>. Consumed /// (dat's ZeroTimeOfYear) to match retail's <c>TimeOfDay + 0x68</c>
/// by <c>SkyDesc.PickCurrentDayGroup</c> as part of the per-day seed. /// 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> /// </summary>
public static int DayOfYear(double ticks) public static int DayOfYear(double ticks)
{ {
if (ticks < 0) ticks = 0; if (ticks < 0) ticks = 0;
double tYear = ticks - Year(ticks) * YearTicks; double shifted = ticks + OriginOffsetTicks;
int year = (int)(shifted / YearTicks);
double tYear = shifted - year * YearTicks;
int d = (int)(tYear / DayTicks); int d = (int)(tYear / DayTicks);
if (d < 0) d = 0; if (d < 0) d = 0;
if (d > 359) d = 359; if (d > 359) d = 359;

View file

@ -393,6 +393,40 @@ public static class SkyDescLoader
Console.WriteLine("[sky-dump] ======== BEGIN SkyDesc dump ========"); Console.WriteLine("[sky-dump] ======== BEGIN SkyDesc dump ========");
Console.WriteLine($"[sky-dump] Region Id={region.Id:X8} Number={region.RegionNumber} Name=\"{region.RegionName}\""); Console.WriteLine($"[sky-dump] Region Id={region.Id:X8} Number={region.RegionNumber} Name=\"{region.RegionName}\"");
// Phase 3f diag — retail TimeOfDay::OnTick uses
// GameTime.ZeroTimeOfYear as an additive tick offset before
// calendar extraction. Our DerethDateTime currently hardcodes
// +7/16 (= 3333.75) for the dayFraction math and IGNORES this
// field. If the dat value is anything other than what we assume,
// our calendar is skewed from retail's at every moment.
var gt = region.GameTime;
if (gt is not null)
{
Console.WriteLine(
$"[sky-dump] GameTime ZeroTimeOfYear={gt.ZeroTimeOfYear} ZeroYear={gt.ZeroYear} " +
$"DayLength={gt.DayLength} DaysPerYear={gt.DaysPerYear} " +
$"YearSpec=\"{gt.YearSpec}\" TimesOfDay.Count={gt.TimesOfDay?.Count ?? 0} " +
$"DaysOfWeek.Count={gt.DaysOfWeek?.Count ?? 0} Seasons.Count={gt.Seasons?.Count ?? 0}");
// Dump every TimeOfDay slot — this is the retail-authoritative
// hour boundary table. Anchors our offset math: if retail
// TimeOfDay[0].Start != our assumed 0, we have our answer.
if (gt.TimesOfDay is not null)
{
for (int i = 0; i < gt.TimesOfDay.Count; i++)
{
var t = gt.TimesOfDay[i];
Console.WriteLine(
$"[sky-dump] TimeOfDay[{i}] Start={t.Start} IsNight={t.IsNight} Name=\"{t.Name}\"");
}
}
}
else
{
Console.WriteLine("[sky-dump] GameTime: null (no calendar info in this region)");
}
Console.WriteLine($"[sky-dump] SkyDesc TickSize={sky.TickSize} LightTickSize={sky.LightTickSize} DayGroups.Count={sky.DayGroups.Count}"); Console.WriteLine($"[sky-dump] SkyDesc TickSize={sky.TickSize} LightTickSize={sky.LightTickSize} DayGroups.Count={sky.DayGroups.Count}");
for (int g = 0; g < sky.DayGroups.Count; g++) for (int g = 0; g < sky.DayGroups.Count; g++)