refactor(runtime): own world environment state
This commit is contained in:
parent
b972f539f7
commit
902076c0a4
27 changed files with 886 additions and 295 deletions
103
src/AcDream.Core/World/DerethCalendar.cs
Normal file
103
src/AcDream.Core/World/DerethCalendar.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
40
src/AcDream.Core/World/SkyDayGroupSelector.cs
Normal file
40
src/AcDream.Core/World/SkyDayGroupSelector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue