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

@ -393,6 +393,40 @@ public static class SkyDescLoader
Console.WriteLine("[sky-dump] ======== BEGIN SkyDesc dump ========");
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}");
for (int g = 0; g < sky.DayGroups.Count; g++)