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:
parent
5f9df4d620
commit
cd8a37a9c8
3 changed files with 110 additions and 31 deletions
|
|
@ -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), " +
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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++)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue