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).
|
||||
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(
|
||||
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
||||
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
|
||||
|
|
|
|||
|
|
@ -108,36 +108,54 @@ public static class DerethDateTime
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// In ACE's calendar, <c>tick 0</c> is defined as "Morningthaw 1,
|
||||
/// 10 P.Y. — Morntide-and-Half", NOT midnight
|
||||
/// (<c>references/ACE/Source/ACE.Common/DerethDateTime.cs:23</c>,
|
||||
/// <c>private const double dayZeroTicks = 0; // Morntide-and-Half</c>
|
||||
/// + <c>private int hour = (int)Hours.Morntide_and_Half;</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// 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>
|
||||
/// 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>. 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>
|
||||
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
|
||||
/// Midsong-and-Half (noon-ish), 1.0 wraps to 0. Corrected for the
|
||||
/// ACE tick-0 = Morntide-and-Half convention (see
|
||||
/// <see cref="DayFractionOriginOffsetTicks"/>).
|
||||
/// 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 + DayFractionOriginOffsetTicks;
|
||||
double shifted = ticks + OriginOffsetTicks;
|
||||
double rem = shifted - Math.Floor(shifted / DayTicks) * DayTicks;
|
||||
return rem / DayTicks;
|
||||
}
|
||||
|
|
@ -175,8 +193,9 @@ public static class DerethDateTime
|
|||
public static Calendar ToCalendar(double ticks)
|
||||
{
|
||||
if (ticks < 0) ticks = 0;
|
||||
int year = (int)(ticks / YearTicks);
|
||||
double tYear = ticks - year * YearTicks;
|
||||
double shifted = ticks + OriginOffsetTicks;
|
||||
int year = (int)(shifted / YearTicks);
|
||||
double tYear = shifted - year * YearTicks;
|
||||
int monthIdx = (int)(tYear / MonthTicks);
|
||||
if (monthIdx > 11) monthIdx = 11;
|
||||
double tMonth = tYear - monthIdx * MonthTicks;
|
||||
|
|
@ -188,15 +207,17 @@ public static class DerethDateTime
|
|||
|
||||
/// <summary>
|
||||
/// Year offset from tick-0 (number of Derethian years elapsed since
|
||||
/// the PY <see cref="ZeroYear"/> anchor). Use for time-delta math.
|
||||
/// For calendar display and for the retail
|
||||
/// <c>SkyDesc::PickCurrentDayGroup</c> LCG seed, use
|
||||
/// 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;
|
||||
return (int)(ticks / YearTicks);
|
||||
double shifted = ticks + OriginOffsetTicks;
|
||||
return (int)(shifted / YearTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -212,14 +233,18 @@ public static class DerethDateTime
|
|||
|
||||
/// <summary>
|
||||
/// Day index within the current Derethian year in [0, 359] (360 days
|
||||
/// per year = 12 months × 30). Matches retail's <c>TimeOfDay + 0x68</c>
|
||||
/// field which is <c>floor(withinYearSec / secsPerDay)</c>. Consumed
|
||||
/// by <c>SkyDesc.PickCurrentDayGroup</c> as part of the per-day seed.
|
||||
/// 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 tYear = ticks - Year(ticks) * YearTicks;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue