refactor(runtime): own world environment state

This commit is contained in:
Erik 2026-07-26 16:45:04 +02:00
parent b972f539f7
commit 902076c0a4
27 changed files with 886 additions and 295 deletions

View file

@ -0,0 +1,103 @@
namespace AcDream.Core.World;
/// <summary>
/// Instance-scoped Dereth calendar projection. Retail stores the Region
/// <c>GameTime.ZeroTimeOfYear</c> beside the active world rather than in a
/// process-global calendar. Keeping that origin on this object lets multiple
/// Runtime sessions use different Region definitions without corrupting one
/// another.
/// </summary>
public sealed class DerethCalendar
{
public DerethCalendar(
double originOffsetTicks = DerethDateTime.DayFractionOriginOffsetTicks)
{
SetOriginOffset(originOffsetTicks);
}
public double OriginOffsetTicks { get; private set; }
/// <summary>
/// Installs the Region DAT origin for this world lifetime.
/// </summary>
public void SetOriginOffset(double originOffsetTicks)
{
if (!double.IsFinite(originOffsetTicks))
throw new ArgumentOutOfRangeException(nameof(originOffsetTicks));
OriginOffsetTicks = originOffsetTicks;
}
public double DayFraction(double ticks)
{
double shifted = Shift(ticks);
double rem = shifted
- Math.Floor(shifted / DerethDateTime.DayTicks)
* DerethDateTime.DayTicks;
return rem / DerethDateTime.DayTicks;
}
public DerethDateTime.HourName CurrentHour(double ticks)
{
int slot = (int)Math.Floor(
DayFraction(ticks) * DerethDateTime.HoursInADay);
slot = Math.Clamp(slot, 0, DerethDateTime.HoursInADay - 1);
return (DerethDateTime.HourName)slot;
}
public bool IsDaytime(double ticks)
{
int hour = (int)CurrentHour(ticks);
return hour >= (int)DerethDateTime.HourName.Dawnsong
&& hour <= (int)DerethDateTime.HourName.WarmtideAndHalf;
}
public DerethDateTime.Calendar ToCalendar(double ticks)
{
double shifted = Shift(ticks);
int relativeYear = (int)(shifted / DerethDateTime.YearTicks);
double withinYear =
shifted - relativeYear * DerethDateTime.YearTicks;
int month = Math.Min(
DerethDateTime.MonthsInAYear - 1,
(int)(withinYear / DerethDateTime.MonthTicks));
double withinMonth =
withinYear - month * DerethDateTime.MonthTicks;
int day = Math.Min(
DerethDateTime.DaysInAMonth,
(int)(withinMonth / DerethDateTime.DayTicks) + 1);
return new DerethDateTime.Calendar(
relativeYear + DerethDateTime.ZeroYear,
(DerethDateTime.MonthName)month,
day,
CurrentHour(ticks));
}
public int Year(double ticks) =>
(int)(Shift(ticks) / DerethDateTime.YearTicks);
public int AbsoluteYear(double ticks) =>
Year(ticks) + DerethDateTime.ZeroYear;
public int DayOfYear(double ticks)
{
double shifted = Shift(ticks);
int year = (int)(shifted / DerethDateTime.YearTicks);
double withinYear =
shifted - year * DerethDateTime.YearTicks;
return Math.Clamp(
(int)(withinYear / DerethDateTime.DayTicks),
0,
DerethDateTime.DaysInAMonth
* DerethDateTime.MonthsInAYear
- 1);
}
private double Shift(double ticks)
{
if (!double.IsFinite(ticks))
throw new ArgumentOutOfRangeException(nameof(ticks));
return Math.Max(0d, ticks) + OriginOffsetTicks;
}
}

View file

@ -123,18 +123,17 @@ public static class DerethDateTime
/// 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.
/// the additive offset. Production installs that value on its
/// instance <see cref="DerethCalendar"/>; this constant remains the
/// offline/static-helper default.
/// </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.
/// Compatibility origin used by the pure static helpers. Production
/// worlds use an instance <see cref="DerethCalendar"/> carrying their
/// Region DAT origin, so multiple sessions never share mutable calendar
/// state.
///
/// <para>
/// Live Dereth dat value: <c>3600</c>. Retail's
@ -148,17 +147,7 @@ public static class DerethDateTime
/// 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;
}
public const double OriginOffsetTicks = DayFractionOriginOffsetTicks;
/// <summary>
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =

View file

@ -0,0 +1,40 @@
namespace AcDream.Core.World;
/// <summary>
/// Retail-verbatim day-group selection from
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c>.
/// </summary>
public static class SkyDayGroupSelector
{
public static int SelectIndex(
int dayGroupCount,
int absoluteYear,
int daysPerYear,
int dayOfYear,
int? forcedIndex = null)
{
if (dayGroupCount <= 0)
return 0;
if (forcedIndex is >= 0 && forcedIndex < dayGroupCount)
return forcedIndex.Value;
if (dayGroupCount == 1)
return 0;
int seed = unchecked(absoluteYear * daysPerYear + dayOfYear);
int mixed = unchecked(
seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652));
float hash = mixed;
if (mixed < 0)
hash += 4294967296.0f;
const float inverseTwoTo32 = 1.0f / 4294967296.0f;
int index = (int)MathF.Floor(
(float)dayGroupCount * hash * inverseTwoTo32);
// Retail snaps the floating-point upper-edge overflow to zero.
return index < 0 || index >= dayGroupCount ? 0 : index;
}
}

View file

@ -221,48 +221,22 @@ public sealed class LoadedSkyDesc
/// </summary>
public int SelectDayGroupIndex(int year, int secondsPerDay, int dayOfYear)
{
if (DayGroups.Count == 0) return 0;
// Env-var override has absolute priority.
var env = System.Environment.GetEnvironmentVariable("ACDREAM_DAY_GROUP");
int? forcedIndex = null;
if (int.TryParse(env, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var forced)
&& forced >= 0 && forced < DayGroups.Count)
{
return forced;
forcedIndex = forced;
}
if (DayGroups.Count == 1) return 0;
// --- Retail SkyDesc::CalcPresentDayGroup @ 0x00500E10 ---
// Step 1: deterministic per-day seed.
int seed = unchecked(year * secondsPerDay + dayOfYear);
// Step 2: 32-bit signed LCG (retail uses x86 silent wrap; force
// unchecked in C#). `0x8ABE1652` is stored as `-0x7541E9AE` in the
// decompile — same bit pattern.
int mixed = unchecked(seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652));
// Step 3: signed-int → float with +2^32 fixup for negative values
// (retail x87 converts `int` to `float` and then adds
// `_DAT_0079920c` ≈ 4294967296.0f when `iVar4 < 0`).
float hashF = (float)mixed;
if (mixed < 0) hashF += 4294967296.0f;
// Step 4: scale to [0, dayGroupCount). `_DAT_007c6f10` is
// `1.0f / 2^32` per reuse pattern (see research doc §2.4).
const float kInv2Pow32 = 1.0f / 4294967296.0f;
float countF = (float)DayGroups.Count;
int index = (int)System.MathF.Floor(countF * hashF * kInv2Pow32);
// Step 5: safety clamp for float rounding at the upper edge.
// Retail does `if (count <= index) index = 0;`. Using the same
// "snap to 0 on overflow" instead of clamping to count-1.
if (index >= DayGroups.Count) index = 0;
if (index < 0) index = 0;
return index;
return SkyDayGroupSelector.SelectIndex(
DayGroups.Count,
year,
secondsPerDay,
dayOfYear,
forcedIndex);
}
/// <summary>

View file

@ -372,7 +372,9 @@ public sealed class WorldTimeService
{
private SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
private DateTimeOffset _lastSyncedWallClockUtc;
private readonly TimeProvider _timeProvider;
private readonly Action<string>? _synchronizationDiagnostic;
private float? _debugDayFractionOverride;
@ -384,10 +386,25 @@ public sealed class WorldTimeService
public double TickSize { get; set; } = 1.0;
public WorldTimeService(SkyStateProvider sky)
: this(sky, new DerethCalendar(), TimeProvider.System)
{
}
public WorldTimeService(
SkyStateProvider sky,
DerethCalendar calendar,
TimeProvider? timeProvider = null,
Action<string>? synchronizationDiagnostic = null)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
Calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
_timeProvider = timeProvider ?? TimeProvider.System;
_synchronizationDiagnostic = synchronizationDiagnostic;
_lastSyncedWallClockUtc = _timeProvider.GetUtcNow();
}
public DerethCalendar Calendar { get; }
/// <summary>
/// Hot-swap the keyframe source — typically called once at world-load
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
@ -404,14 +421,14 @@ public sealed class WorldTimeService
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = System.DateTime.UtcNow;
_lastSyncedWallClockUtc = _timeProvider.GetUtcNow();
_debugDayFractionOverride = null;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
if (_synchronizationDiagnostic is not null)
{
var df = DerethDateTime.DayFraction(serverTicks);
var cal = DerethDateTime.ToCalendar(serverTicks);
System.Console.WriteLine(
var df = Calendar.DayFraction(serverTicks);
var cal = Calendar.ToCalendar(serverTicks);
_synchronizationDiagnostic(
$"[sky-dump] SyncFromServer: ticks={serverTicks:F1} dayFraction={df:F4} " +
$"calendar=PY{cal.Year} {cal.Month} {cal.Day} {cal.Hour}");
}
@ -430,14 +447,17 @@ public sealed class WorldTimeService
public void ClearDebugTime() => _debugDayFractionOverride = null;
/// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
/// Current ticks at the injected <see cref="TimeProvider"/>'s UTC time,
/// advanced from the last sync by real-time elapsed seconds times
/// <see cref="TickSize"/>.
/// </summary>
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
double elapsed =
(_timeProvider.GetUtcNow() - _lastSyncedWallClockUtc)
.TotalSeconds;
return _lastSyncedTicks + elapsed * TickSize;
}
}
@ -449,7 +469,7 @@ public sealed class WorldTimeService
{
if (_debugDayFractionOverride.HasValue)
return _debugDayFractionOverride.Value;
return DerethDateTime.DayFraction(NowTicks);
return Calendar.DayFraction(NowTicks);
}
}
@ -461,7 +481,7 @@ public sealed class WorldTimeService
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);
public DerethDateTime.Calendar CurrentCalendar =>
DerethDateTime.ToCalendar(NowTicks);
Calendar.ToCalendar(NowTicks);
public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks);
public bool IsDaytime => Calendar.IsDaytime(NowTicks);
}