diff --git a/docs/research/2026-04-23-daygroup-selection.md b/docs/research/2026-04-23-daygroup-selection.md
index 9307f9fc..bc783942 100644
--- a/docs/research/2026-04-23-daygroup-selection.md
+++ b/docs/research/2026-04-23-daygroup-selection.md
@@ -89,7 +89,7 @@ void __fastcall FUN_00501990(uint *param_1)
| Symbol | Bytes | Role |
|---|---|---|
-| `0x6a42fdb2` | 1,782,399,410 | LCG multiplier (AC-specific, not a named-PRNG constant). |
+| `0x6a42fdb2` | 1,782,775,218 | LCG multiplier (AC-specific, not a named-PRNG constant). |
| `-0x7541e9ae` | `0x8ABE1652` unsigned | LCG increment. |
| `_DAT_0079920c` | ~4.29497e9f (2^32) | Standard signed-int→unsigned-float fixup constant. |
| `_DAT_007c6f10` | (see §2.4) | Scale factor; interpreted as `1.0 / 2^32 ≈ 2.32830644e-10f`. Same constant is reused by all deterministic AC LCG picks (e.g. `FUN_00504060` at `chunk_00500000.c:4042` for hash-chain lookup). |
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index daf3cea3..37b9884b 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -879,6 +879,7 @@ internal sealed class SessionPlayerCompositionPhase
d.Communication,
d.Actions,
d.PlayerController,
+ d.WorldEnvironment.Runtime,
worldReveal,
d.UpdateClock,
live.SelectionInteractions);
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index c6d238e3..0ce5aa92 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -116,8 +116,7 @@ public sealed class GameWindow :
_renderResourceLifetime = new();
private readonly AcDream.App.Rendering.GlConstructionCleanupLedger
_glConstructionCleanup = new();
- private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
- new(Console.WriteLine);
+ private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;
private readonly GameWindowLifetime _lifetime = new();
private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings;
@@ -566,6 +565,13 @@ public sealed class GameWindow :
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
+ _worldEnvironment = new AcDream.App.World.WorldEnvironmentController(
+ new AcDream.Runtime.World.RuntimeWorldEnvironmentState(
+ log: Console.WriteLine,
+ timeSyncDiagnostic:
+ options.DumpSky ? Console.WriteLine : null),
+ options.ForcedDayGroupIndex,
+ Console.WriteLine);
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
_runtimeCharacter = new RuntimeCharacterState();
_runtimeActions = new RuntimeActionState(
diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
index 47f65db1..f18e5863 100644
--- a/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
+++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
@@ -29,7 +29,7 @@ internal sealed class RuntimeRenderFrameTitleFactsSource : IRenderFrameTitleFact
public RenderFrameTitleFacts Capture() => new(
_world.Entities.Count,
_animations.Count,
- DerethDateTime.ToCalendar(_time.NowTicks),
+ _time.CurrentCalendar,
_time.DayFraction);
}
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
index cbe52b2f..4a9fe121 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
@@ -7,6 +7,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
+using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
namespace AcDream.App.Runtime;
@@ -38,6 +39,7 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
+ RuntimeWorldEnvironmentState environment,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
SelectionInteractionController selection)
@@ -51,6 +53,7 @@ internal sealed class CurrentGameRuntimeAdapter
communication,
actions,
movement,
+ environment,
worldReveal,
clock);
entityObjects.BindEventContext(
@@ -84,6 +87,7 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeChatView Chat => _view.Chat;
public IRuntimeActionView Actions => _view.Actions;
public IRuntimeMovementView Movement => _view.Movement;
+ public IRuntimeWorldEnvironmentView Environment => _view.Environment;
public IRuntimePortalView Portal => _view.Portal;
public IRuntimeSessionCommands Session => _commands;
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
index 4d46037f..cf50cec0 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
@@ -7,6 +7,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
+using AcDream.Runtime.World;
namespace AcDream.App.Runtime;
@@ -27,6 +28,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView;
private readonly IRuntimeMovementView _movementView;
+ private readonly IRuntimeWorldEnvironmentView _environmentView;
private readonly PortalView _portalView;
private bool _active = true;
@@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
+ RuntimeWorldEnvironmentState environment,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock)
{
@@ -61,6 +64,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
actions ?? throw new ArgumentNullException(nameof(actions))).View;
_movementView = (
movement ?? throw new ArgumentNullException(nameof(movement))).View;
+ _environmentView = environment
+ ?? throw new ArgumentNullException(nameof(environment));
_portalView = new PortalView(
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
}
@@ -102,6 +107,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
public IRuntimeChatView Chat => _chatView;
public IRuntimeActionView Actions => _actionView;
public IRuntimeMovementView Movement => _movementView;
+ public IRuntimeWorldEnvironmentView Environment => _environmentView;
public IRuntimePortalView Portal => _portalView;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
@@ -120,6 +126,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_chatView.Count,
_actionView.Snapshot,
_movementView.Snapshot,
+ _environmentView.Snapshot,
_portalView.Snapshot);
internal void Deactivate() => _active = false;
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index 1ba025a1..8324c504 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -38,6 +38,7 @@ public sealed record RuntimeOptions(
bool DevTools,
bool UncappedRendering,
bool DumpMoveTruth,
+ bool DumpSky,
bool NoAudio,
bool EnableSkyPesDebug,
int HidePartIndex,
@@ -51,6 +52,7 @@ public sealed record RuntimeOptions(
bool UiProbeDump,
string? UiProbeScript,
string? AutomationArtifactDirectory,
+ int? ForcedDayGroupIndex,
float FogStartMultiplier,
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets,
@@ -91,6 +93,7 @@ public sealed record RuntimeOptions(
// sole way to measure truly uncapped renderer throughput.
UncappedRendering: IsExactlyOne(env("ACDREAM_UNCAPPED_RENDER")),
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
+ DumpSky: IsExactlyOne(env("ACDREAM_DUMP_SKY")),
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
@@ -110,6 +113,8 @@ public sealed record RuntimeOptions(
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
AutomationArtifactDirectory:
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
+ ForcedDayGroupIndex:
+ TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")),
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs
index c79a65ef..0e127200 100644
--- a/src/AcDream.App/World/WorldEnvironmentController.cs
+++ b/src/AcDream.App/World/WorldEnvironmentController.cs
@@ -1,61 +1,59 @@
using AcDream.App.Rendering;
using AcDream.Core.World;
+using AcDream.Runtime.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.World;
///
-/// Owns the live Dereth clock, selected retail day group, weather state, and
-/// server environment overrides as one coherent environment lifetime.
+/// Graphical DAT/presentation adapter over the one
+/// owner. Runtime owns clock,
+/// weather, day selection, and server overrides; App retains raw sky DAT
+/// objects because they are renderer inputs.
///
internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
{
- private static readonly WeatherKind[] DebugWeatherKinds =
- [
- WeatherKind.Clear,
- WeatherKind.Overcast,
- WeatherKind.Rain,
- WeatherKind.Snow,
- WeatherKind.Storm,
- ];
-
private readonly Action _log;
+ private readonly int? _forcedDayGroupIndex;
private LoadedSkyDesc? _loadedSkyDesc;
- private long _loadedSkyDayIndex = long.MinValue;
- private int _timeDebugStep;
- private int _weatherDebugStep;
- private bool _initializationClaimed;
public WorldEnvironmentController(Action? log = null)
: this(
- new WorldTimeService(SkyStateProvider.Default()),
- new WeatherSystem(),
+ new RuntimeWorldEnvironmentState(log: log),
+ forcedDayGroupIndex: null,
log)
{
}
internal WorldEnvironmentController(
- WorldTimeService worldTime,
- WeatherSystem weather,
+ RuntimeWorldEnvironmentState runtime,
+ int? forcedDayGroupIndex = null,
Action? log = null)
{
- WorldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
- Weather = weather ?? throw new ArgumentNullException(nameof(weather));
+ Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _forcedDayGroupIndex = forcedDayGroupIndex;
_log = log ?? (_ => { });
}
- public WorldTimeService WorldTime { get; }
+ public RuntimeWorldEnvironmentState Runtime { get; }
+ public WorldTimeService WorldTime => Runtime.WorldTime;
+ public WeatherSystem Weather => Runtime.Weather;
- public WeatherSystem Weather { get; }
-
- public DayGroupData? ActiveDayGroup { get; private set; }
+ public DayGroupData? ActiveDayGroup
+ {
+ get
+ {
+ int index = Runtime.ActiveDayGroupIndex;
+ return _loadedSkyDesc is not null
+ && index >= 0
+ && index < _loadedSkyDesc.DayGroups.Count
+ ? _loadedSkyDesc.DayGroups[index]
+ : null;
+ }
+ }
public float DayFraction => (float)WorldTime.DayFraction;
- ///
- /// Loads the Region DAT environment and seeds the pre-session clock exactly
- /// as the former GameWindow.OnLoad body did.
- ///
public void Initialize(Region region)
{
ArgumentNullException.ThrowIfNull(region);
@@ -68,182 +66,52 @@ internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
LoadedSkyDesc? loadedSkyDesc,
double? zeroTimeOfYear)
{
- if (_initializationClaimed)
+ if (Runtime.IsInitialized)
{
throw new InvalidOperationException(
- "The world environment is a one-shot GameWindow lifetime owner.");
+ "The world environment is a one-shot Runtime lifetime owner.");
}
- _initializationClaimed = true;
- _loadedSkyDesc = loadedSkyDesc;
- _loadedSkyDayIndex = long.MinValue;
- ActiveDayGroup = null;
-
- // Region GameTime is authoritative when present. A Region without it
- // gets the documented offline fallback rather than inheriting mutable
- // process-global state from an earlier window or test lifetime.
double origin = zeroTimeOfYear
?? DerethDateTime.DayFractionOriginOffsetTicks;
- DerethDateTime.SetOriginOffsetFromDat(origin);
-
- if (_loadedSkyDesc is not null)
+ if (zeroTimeOfYear.HasValue)
{
- // SkyDesc.TickSize is retail's next-sky-update throttle, not the
- // rate of PortalYearTicks. ACE advances the server clock at one
- // tick per real second, so client extrapolation must remain 1.0.
- WorldTime.TickSize = 1.0;
-
- if (zeroTimeOfYear.HasValue)
- {
- _log(
- $"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} " +
- $"(was default {DerethDateTime.DayFractionOriginOffsetTicks})");
- }
-
_log(
- $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
- $"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
- $"LightTickSize={_loadedSkyDesc.LightTickSize}");
-
- // The initial roll intentionally precedes the offline noon seed,
- // preserving the accepted OnLoad order. The first server sync
- // below will select the authoritative day.
- RefreshSkyForCurrentDay();
+ $"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} "
+ + $"(was default {DerethDateTime.DayFractionOriginOffsetTicks})");
}
- WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
+ RuntimeWorldDayGroupDefinition[] groups =
+ loadedSkyDesc?.DayGroups.Select(
+ group => new RuntimeWorldDayGroupDefinition(
+ group.Name,
+ group.ChanceOfOccur,
+ group.SkyObjects.Count,
+ new SkyStateProvider(
+ group.SkyTimes.Select(value => value.Keyframe).ToList())))
+ .ToArray()
+ ?? [];
+
+ var definition = new RuntimeWorldEnvironmentDefinition(
+ origin,
+ loadedSkyDesc?.TickSize ?? 1.0,
+ loadedSkyDesc?.LightTickSize ?? 1.0,
+ groups,
+ _forcedDayGroupIndex);
+
+ Runtime.Initialize(definition);
+ _loadedSkyDesc = loadedSkyDesc;
}
- public void SynchronizeFromServer(double ticks)
- {
- WorldTime.SyncFromServer(ticks);
- RefreshSkyForCurrentDay();
- }
+ public void SynchronizeFromServer(double ticks) =>
+ Runtime.SynchronizeFromServer(ticks);
- ///
- /// Environment packet bridge researched from
- /// CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20. The current
- /// fog approximation and missing centered UI-sound playback remain
- /// explicitly registered as TS-55 and TS-54 respectively.
- ///
- public void ApplyAdminEnvirons(uint environChangeType)
- {
- if (environChangeType <= 0x06u)
- {
- Weather.Override = (EnvironOverride)environChangeType;
- _log(
- $"live: AdminEnvirons fog override = " +
- $"{(EnvironOverride)environChangeType}");
- return;
- }
+ public void ApplyAdminEnvirons(uint environChangeType) =>
+ _ = Runtime.ApplyAdminEnvirons(environChangeType);
- string name = environChangeType switch
- {
- 0x65u => "RoarSound",
- 0x66u => "BellSound",
- 0x67u => "Chant1Sound",
- 0x68u => "Chant2Sound",
- 0x69u => "DarkWhispers1Sound",
- 0x6Au => "DarkWhispers2Sound",
- 0x6Bu => "DarkLaughSound",
- 0x6Cu => "DarkWindSound",
- 0x6Du => "DarkSpeechSound",
- 0x6Eu => "DrumsSound",
- 0x6Fu => "GhostSpeakSound",
- 0x70u => "BreathingSound",
- 0x71u => "HowlSound",
- 0x72u => "LostSoulsSound",
- 0x75u => "SquealSound",
- 0x76u => "Thunder1Sound",
- 0x77u => "Thunder2Sound",
- 0x78u => "Thunder3Sound",
- 0x79u => "Thunder4Sound",
- 0x7Au => "Thunder5Sound",
- 0x7Bu => "Thunder6Sound",
- _ => $"Unknown(0x{environChangeType:X2})",
- };
+ public void RefreshSkyForCurrentDay() => Runtime.RefreshDayGroup();
- _log(
- $"live: AdminEnvirons sound cue = {name} " +
- $"(0x{environChangeType:X2}) — audio binding pending");
- }
+ public string CycleTimeOfDay() => Runtime.CycleTimeOfDay();
- ///
- /// Selects the active DAT day group when the authoritative Dereth day
- /// changes. The picker itself is the retail-verbatim
- /// SkyDesc::CalcPresentDayGroup @ 0x00500E10 port (older-build
- /// cross-reference FUN_00501990).
- ///
- public void RefreshSkyForCurrentDay()
- {
- if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
- return;
-
- double ticks = WorldTime.NowTicks;
- int absYear = DerethDateTime.AbsoluteYear(ticks);
- int dayOfYear = DerethDateTime.DayOfYear(ticks);
- int daysPerYear = DerethDateTime.DaysInAMonth
- * DerethDateTime.MonthsInAYear;
- long dayIndex = (long)absYear * 360 + dayOfYear;
-
- int idx = _loadedSkyDesc.SelectDayGroupIndex(
- absYear,
- daysPerYear,
- dayOfYear);
- DayGroupData? group = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
- ? _loadedSkyDesc.DayGroups[idx]
- : null;
-
- bool dayChanged = dayIndex != _loadedSkyDayIndex;
- bool groupChanged = !ReferenceEquals(group, ActiveDayGroup);
- if (!dayChanged && !groupChanged)
- return;
-
- _loadedSkyDayIndex = dayIndex;
- ActiveDayGroup = group;
-
- if (group is null || group.SkyTimes.Count == 0)
- return;
-
- WorldTime.SetProvider(
- new SkyStateProvider(
- group.SkyTimes.Select(s => s.Keyframe).ToList()));
- Weather.SetKindFromDayGroupName(group.Name);
-
- _log(
- $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{group.Name}\" " +
- $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjects.Count} objects, " +
- $"{group.SkyTimes.Count} keyframes, weather={Weather.Kind})");
- }
-
- public string CycleTimeOfDay()
- {
- _timeDebugStep = (_timeDebugStep + 1) % 5;
- float? selection = _timeDebugStep switch
- {
- 0 => null,
- 1 => 0.0f,
- 2 => 0.25f,
- 3 => 0.5f,
- 4 => 0.75f,
- _ => null,
- };
-
- if (selection.HasValue)
- {
- WorldTime.SetDebugTime(selection.Value);
- return $"Time override = {selection.Value:F2}";
- }
-
- WorldTime.ClearDebugTime();
- return "Time override cleared";
- }
-
- public string CycleWeather()
- {
- _weatherDebugStep = (_weatherDebugStep + 1) % DebugWeatherKinds.Length;
- WeatherKind kind = DebugWeatherKinds[_weatherDebugStep];
- Weather.ForceWeather(kind);
- return $"Weather = {kind}";
- }
+ public string CycleWeather() => Runtime.CycleWeather();
}
diff --git a/src/AcDream.Core/World/DerethCalendar.cs b/src/AcDream.Core/World/DerethCalendar.cs
new file mode 100644
index 00000000..1aef2e35
--- /dev/null
+++ b/src/AcDream.Core/World/DerethCalendar.cs
@@ -0,0 +1,103 @@
+namespace AcDream.Core.World;
+
+///
+/// Instance-scoped Dereth calendar projection. Retail stores the Region
+/// GameTime.ZeroTimeOfYear 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.
+///
+public sealed class DerethCalendar
+{
+ public DerethCalendar(
+ double originOffsetTicks = DerethDateTime.DayFractionOriginOffsetTicks)
+ {
+ SetOriginOffset(originOffsetTicks);
+ }
+
+ public double OriginOffsetTicks { get; private set; }
+
+ ///
+ /// Installs the Region DAT origin for this world lifetime.
+ ///
+ 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;
+ }
+}
diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs
index 5e65b67a..63b3a47c 100644
--- a/src/AcDream.Core/World/DerethDateTime.cs
+++ b/src/AcDream.Core/World/DerethDateTime.cs
@@ -123,18 +123,17 @@ public static class DerethDateTime
/// Morntide-and-Half". This is a fallback — retail reads
/// GameTime.ZeroTimeOfYear from the Region dat (verified
/// 3600 in Dereth, 2026-04-23 live dump) and uses that as
- /// the additive offset. We override
- /// once the dat loads; this constant is only for offline tests.
+ /// the additive offset. Production installs that value on its
+ /// instance ; this constant remains the
+ /// offline/static-helper default.
///
public const double DayFractionOriginOffsetTicks = (7.0 / 16.0) * DayTicks; // 3333.75
///
- /// Additive tick offset applied before every calendar extraction
- /// (DayFraction / Year / DayOfYear / AbsoluteYear). Populated from
- /// the Region dat's GameTime.ZeroTimeOfYear via
- /// at Region load. Defaults to
- /// 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 carrying their
+ /// Region DAT origin, so multiple sessions never share mutable calendar
+ /// state.
///
///
/// Live Dereth dat value: 3600. Retail's
@@ -148,17 +147,7 @@ public static class DerethDateTime
/// corrected and broke DG selection; reverted in the same commit.)
///
///
- public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
-
- ///
- /// Adopt the Region dat's GameTime.ZeroTimeOfYear as the
- /// calendar-extraction offset. Idempotent; safe to call on every
- /// Region reload (though in practice Dereth is the only region).
- ///
- public static void SetOriginOffsetFromDat(double zeroTimeOfYear)
- {
- OriginOffsetTicks = zeroTimeOfYear;
- }
+ public const double OriginOffsetTicks = DayFractionOriginOffsetTicks;
///
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
diff --git a/src/AcDream.Core/World/SkyDayGroupSelector.cs b/src/AcDream.Core/World/SkyDayGroupSelector.cs
new file mode 100644
index 00000000..6589c9f7
--- /dev/null
+++ b/src/AcDream.Core/World/SkyDayGroupSelector.cs
@@ -0,0 +1,40 @@
+namespace AcDream.Core.World;
+
+///
+/// Retail-verbatim day-group selection from
+/// SkyDesc::CalcPresentDayGroup @ 0x00500E10.
+///
+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;
+ }
+}
diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs
index f6f61d15..3311cef4 100644
--- a/src/AcDream.Core/World/SkyDescLoader.cs
+++ b/src/AcDream.Core/World/SkyDescLoader.cs
@@ -221,48 +221,22 @@ public sealed class LoadedSkyDesc
///
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);
}
///
diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs
index 0120e84a..4cbe2608 100644
--- a/src/AcDream.Core/World/SkyState.cs
+++ b/src/AcDream.Core/World/SkyState.cs
@@ -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? _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? 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; }
+
///
/// Hot-swap the keyframe source — typically called once at world-load
/// time after the Region dat has been parsed by .
@@ -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;
///
- /// Current ticks at , advanced from the
- /// last sync by real-time elapsed seconds times .
+ /// Current ticks at the injected 's UTC time,
+ /// advanced from the last sync by real-time elapsed seconds times
+ /// .
///
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);
}
diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs
index 1cdd1919..d0e54246 100644
--- a/src/AcDream.Runtime/GameRuntimeEvents.cs
+++ b/src/AcDream.Runtime/GameRuntimeEvents.cs
@@ -188,6 +188,10 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.Magic.Revision}:" +
$"{checkpoint.Actions.Magic.LastRequestedSpellId:X8}:" +
$"{checkpoint.Actions.Magic.LastRequestedTargetId:X8};" +
+ $"environment={checkpoint.Environment.Revision}:" +
+ $"{checkpoint.Environment.ActiveDayGroupIndex}:" +
+ $"{(int)checkpoint.Environment.Weather}:" +
+ $"{(int)checkpoint.Environment.EnvironOverride};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}
diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs
index 47511ce8..f974c1a3 100644
--- a/src/AcDream.Runtime/GameRuntimeViews.cs
+++ b/src/AcDream.Runtime/GameRuntimeViews.cs
@@ -1,4 +1,5 @@
using AcDream.Core.Physics;
+using AcDream.Runtime.World;
namespace AcDream.Runtime;
@@ -121,6 +122,7 @@ public readonly record struct RuntimeStateCheckpoint(
int ChatCount,
RuntimeActionSnapshot Actions,
RuntimeMovementSnapshot Movement,
+ RuntimeWorldEnvironmentSnapshot Environment,
RuntimePortalSnapshot Portal);
public interface IGameRuntimeView
@@ -147,6 +149,8 @@ public interface IGameRuntimeView
IRuntimeMovementView Movement { get; }
+ IRuntimeWorldEnvironmentView Environment { get; }
+
IRuntimePortalView Portal { get; }
RuntimeStateCheckpoint CaptureCheckpoint();
diff --git a/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs b/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs
new file mode 100644
index 00000000..bb63e94c
--- /dev/null
+++ b/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs
@@ -0,0 +1,313 @@
+using AcDream.Core.World;
+
+namespace AcDream.Runtime.World;
+
+public sealed record RuntimeWorldDayGroupDefinition(
+ string Name,
+ float ChanceOfOccur,
+ int SkyObjectCount,
+ SkyStateProvider Sky)
+{
+ public string Name { get; } = Name ?? string.Empty;
+ public SkyStateProvider Sky { get; } =
+ Sky ?? throw new ArgumentNullException(nameof(Sky));
+}
+
+public sealed class RuntimeWorldEnvironmentDefinition
+{
+ private readonly RuntimeWorldDayGroupDefinition[] _dayGroups;
+
+ public RuntimeWorldEnvironmentDefinition(
+ double originOffsetTicks,
+ double sourceTickSize,
+ double lightTickSize,
+ IEnumerable? dayGroups,
+ int? forcedDayGroupIndex = null)
+ {
+ if (!double.IsFinite(originOffsetTicks))
+ throw new ArgumentOutOfRangeException(nameof(originOffsetTicks));
+ if (!double.IsFinite(sourceTickSize))
+ throw new ArgumentOutOfRangeException(nameof(sourceTickSize));
+ if (!double.IsFinite(lightTickSize))
+ throw new ArgumentOutOfRangeException(nameof(lightTickSize));
+
+ OriginOffsetTicks = originOffsetTicks;
+ SourceTickSize = sourceTickSize;
+ LightTickSize = lightTickSize;
+ _dayGroups = dayGroups?.ToArray() ?? [];
+ if (forcedDayGroupIndex is < 0
+ || forcedDayGroupIndex >= _dayGroups.Length)
+ {
+ forcedDayGroupIndex = null;
+ }
+ ForcedDayGroupIndex = forcedDayGroupIndex;
+ }
+
+ public double OriginOffsetTicks { get; }
+ public double SourceTickSize { get; }
+ public double LightTickSize { get; }
+ public IReadOnlyList DayGroups => _dayGroups;
+ public int? ForcedDayGroupIndex { get; }
+}
+
+public enum RuntimeEnvironmentEffectKind
+{
+ Unknown,
+ FogOverride,
+ SoundCue,
+}
+
+public enum RuntimeEnvironmentSoundCue
+{
+ Roar = 0x65,
+ Bell = 0x66,
+ Chant1 = 0x67,
+ Chant2 = 0x68,
+ DarkWhispers1 = 0x69,
+ DarkWhispers2 = 0x6A,
+ DarkLaugh = 0x6B,
+ DarkWind = 0x6C,
+ DarkSpeech = 0x6D,
+ Drums = 0x6E,
+ GhostSpeak = 0x6F,
+ Breathing = 0x70,
+ Howl = 0x71,
+ LostSouls = 0x72,
+ Squeal = 0x75,
+ Thunder1 = 0x76,
+ Thunder2 = 0x77,
+ Thunder3 = 0x78,
+ Thunder4 = 0x79,
+ Thunder5 = 0x7A,
+ Thunder6 = 0x7B,
+}
+
+public readonly record struct RuntimeEnvironmentEffect(
+ RuntimeEnvironmentEffectKind Kind,
+ uint RawValue,
+ EnvironOverride FogOverride = EnvironOverride.None,
+ RuntimeEnvironmentSoundCue? SoundCue = null);
+
+public readonly record struct RuntimeWorldEnvironmentSnapshot(
+ long Revision,
+ bool IsInitialized,
+ int ActiveDayGroupIndex,
+ long ActiveDayIndex,
+ WeatherKind Weather,
+ EnvironOverride EnvironOverride);
+
+public interface IRuntimeWorldEnvironmentView
+{
+ RuntimeWorldEnvironmentSnapshot Snapshot { get; }
+}
+
+///
+/// Instance-scoped owner of the retail world clock, deterministic day-group
+/// choice, weather label, and AdminEnvirons state. Raw DAT objects and sky
+/// drawing remain graphical-host projections.
+///
+public sealed class RuntimeWorldEnvironmentState
+ : IRuntimeWorldEnvironmentView
+{
+ private static readonly WeatherKind[] DebugWeatherKinds =
+ [
+ WeatherKind.Clear,
+ WeatherKind.Overcast,
+ WeatherKind.Rain,
+ WeatherKind.Snow,
+ WeatherKind.Storm,
+ ];
+
+ private readonly Action _log;
+ private RuntimeWorldEnvironmentDefinition? _definition;
+ private long _activeDayIndex = long.MinValue;
+ private int _timeDebugStep;
+ private int _weatherDebugStep;
+ private long _revision;
+
+ public RuntimeWorldEnvironmentState(
+ TimeProvider? timeProvider = null,
+ Action? log = null,
+ Action? timeSyncDiagnostic = null)
+ {
+ WorldTime = new WorldTimeService(
+ SkyStateProvider.Default(),
+ new DerethCalendar(),
+ timeProvider,
+ timeSyncDiagnostic);
+ Weather = new WeatherSystem();
+ _log = log ?? (_ => { });
+ }
+
+ public WorldTimeService WorldTime { get; }
+ public WeatherSystem Weather { get; }
+ public int ActiveDayGroupIndex { get; private set; } = -1;
+ public bool IsInitialized => _definition is not null;
+
+ public RuntimeWorldEnvironmentSnapshot Snapshot => new(
+ _revision,
+ IsInitialized,
+ ActiveDayGroupIndex,
+ _activeDayIndex,
+ Weather.Kind,
+ Weather.Override);
+
+ public void Initialize(RuntimeWorldEnvironmentDefinition definition)
+ {
+ ArgumentNullException.ThrowIfNull(definition);
+ if (_definition is not null)
+ {
+ throw new InvalidOperationException(
+ "The world environment is a one-shot Runtime lifetime owner.");
+ }
+
+ _definition = definition;
+ WorldTime.Calendar.SetOriginOffset(definition.OriginOffsetTicks);
+ WorldTime.TickSize = 1.0;
+ ActiveDayGroupIndex = -1;
+ _activeDayIndex = long.MinValue;
+ _revision++;
+
+ if (definition.DayGroups.Count > 0)
+ {
+ _log(
+ $"sky: loaded Region 0x13000000 — {definition.DayGroups.Count} day groups, "
+ + $"SkyDesc.TickSize={definition.SourceTickSize} (throttle, not rate), "
+ + $"LightTickSize={definition.LightTickSize}");
+ RefreshDayGroup();
+ }
+
+ // Preserve the accepted retail-port initialization order: select the
+ // initial group, then seed the offline clock at noon.
+ WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
+ _revision++;
+ }
+
+ public void SynchronizeFromServer(double ticks)
+ {
+ WorldTime.SyncFromServer(ticks);
+ _revision++;
+ if (IsInitialized)
+ RefreshDayGroup();
+ }
+
+ ///
+ /// Port of the state distinction in
+ /// CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20.
+ /// Graphical audio playback consumes the returned typed sound cue.
+ ///
+ public RuntimeEnvironmentEffect ApplyAdminEnvirons(uint changeType)
+ {
+ if (changeType <= 0x06u)
+ {
+ var value = (EnvironOverride)changeType;
+ Weather.Override = value;
+ _revision++;
+ _log($"live: AdminEnvirons fog override = {value}");
+ return new RuntimeEnvironmentEffect(
+ RuntimeEnvironmentEffectKind.FogOverride,
+ changeType,
+ value);
+ }
+
+ RuntimeEnvironmentSoundCue? cue =
+ Enum.IsDefined(typeof(RuntimeEnvironmentSoundCue), (int)changeType)
+ ? (RuntimeEnvironmentSoundCue)(int)changeType
+ : null;
+ if (cue is null)
+ {
+ _log(
+ $"live: AdminEnvirons sound cue = Unknown(0x{changeType:X2}) "
+ + $"(0x{changeType:X2}) — audio binding pending");
+ return new RuntimeEnvironmentEffect(
+ RuntimeEnvironmentEffectKind.Unknown,
+ changeType);
+ }
+
+ _log(
+ $"live: AdminEnvirons sound cue = {cue}Sound "
+ + $"(0x{changeType:X2}) — audio binding pending");
+ return new RuntimeEnvironmentEffect(
+ RuntimeEnvironmentEffectKind.SoundCue,
+ changeType,
+ SoundCue: cue);
+ }
+
+ ///
+ /// Selects the ordered day group with the exact
+ /// SkyDesc::CalcPresentDayGroup @ 0x00500E10 integer/float
+ /// algorithm.
+ ///
+ public void RefreshDayGroup()
+ {
+ RuntimeWorldEnvironmentDefinition? definition = _definition;
+ if (definition is null)
+ return;
+ if (definition.DayGroups.Count == 0)
+ return;
+
+ double ticks = WorldTime.NowTicks;
+ DerethCalendar calendar = WorldTime.Calendar;
+ int absoluteYear = calendar.AbsoluteYear(ticks);
+ int dayOfYear = calendar.DayOfYear(ticks);
+ int daysPerYear =
+ DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear;
+ long dayIndex = (long)absoluteYear * daysPerYear + dayOfYear;
+ int index = SkyDayGroupSelector.SelectIndex(
+ definition.DayGroups.Count,
+ absoluteYear,
+ daysPerYear,
+ dayOfYear,
+ definition.ForcedDayGroupIndex);
+
+ if (dayIndex == _activeDayIndex && index == ActiveDayGroupIndex)
+ return;
+
+ _activeDayIndex = dayIndex;
+ ActiveDayGroupIndex = index;
+ RuntimeWorldDayGroupDefinition group = definition.DayGroups[index];
+ WorldTime.SetProvider(group.Sky);
+ Weather.SetKindFromDayGroupName(group.Name);
+ _revision++;
+
+ _log(
+ $"sky: PY{absoluteYear} day{dayOfYear} → DayGroup[{index}] \"{group.Name}\" "
+ + $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjectCount} objects, "
+ + $"{group.Sky.KeyframeCount} keyframes, weather={Weather.Kind})");
+ }
+
+ public string CycleTimeOfDay()
+ {
+ _timeDebugStep = (_timeDebugStep + 1) % 5;
+ float? selection = _timeDebugStep switch
+ {
+ 0 => null,
+ 1 => 0.0f,
+ 2 => 0.25f,
+ 3 => 0.5f,
+ 4 => 0.75f,
+ _ => null,
+ };
+
+ _revision++;
+ if (selection.HasValue)
+ {
+ WorldTime.SetDebugTime(selection.Value);
+ return $"Time override = {selection.Value:F2}";
+ }
+
+ WorldTime.ClearDebugTime();
+ return "Time override cleared";
+ }
+
+ public string CycleWeather()
+ {
+ _weatherDebugStep =
+ (_weatherDebugStep + 1) % DebugWeatherKinds.Length;
+ WeatherKind kind = DebugWeatherKinds[_weatherDebugStep];
+ Weather.ForceWeather(kind);
+ _revision++;
+ return $"Weather = {kind}";
+ }
+
+}
diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
index 938cfe61..8c2a356d 100644
--- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
+++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
@@ -236,6 +236,8 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeChatView Chat => null!;
public IRuntimeActionView Actions => null!;
public IRuntimeMovementView Movement => null!;
+ public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
+ null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!;
diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
index 57788833..bc2d577d 100644
--- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
@@ -209,6 +209,8 @@ public sealed class GameplayInputCommandControllerTests
public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeActionView Actions => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException();
+ public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
+ throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException();
public RuntimeStateCheckpoint CaptureCheckpoint() =>
throw new NotSupportedException();
diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
index 83b1e219..8efa0002 100644
--- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
@@ -169,7 +169,7 @@ public sealed class GameWindowSlice8BoundaryTests
"WorldRenderComposition.cs"));
Assert.Contains(
- "private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =",
+ "private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;",
source,
StringComparison.Ordinal);
Assert.Contains(
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index 9d14f491..1b720ba1 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -23,6 +23,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
+using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@@ -783,6 +784,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_combatModeBinding =
CombatModeOperations.BindOwned(CombatMode);
MovementState = new RuntimeLocalPlayerMovementState();
+ Environment = new RuntimeWorldEnvironmentState();
MovementInput = new DispatcherMovementInputSource(MovementState);
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
@@ -833,6 +835,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication,
Actions,
MovementState,
+ Environment,
WorldReveal,
Clock,
selectionController);
@@ -853,6 +856,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public RuntimeLocalPlayerMovementState MovementState { get; }
+ public RuntimeWorldEnvironmentState Environment { get; }
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatModeOperations CombatMode { get; }
diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
index 234a91a3..9d9d5f51 100644
--- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
@@ -338,6 +338,26 @@ public sealed class RuntimeOptionsTests
Assert.Equal(0.95f, invalid.FogEndMultiplier);
}
+ [Fact]
+ public void DayGroupOverride_IsReadOnceIntoTypedOptions()
+ {
+ Assert.Null(
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ EmptyEnv()).ForcedDayGroupIndex);
+ Assert.Null(
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_DAY_GROUP"] = "-1" }))
+ .ForcedDayGroupIndex);
+ Assert.Equal(
+ 7,
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_DAY_GROUP"] = "7" }))
+ .ForcedDayGroupIndex);
+ }
+
[Fact]
public void DiagnosticFlags_RespectExactValueOne()
{
@@ -346,6 +366,7 @@ public sealed class RuntimeOptionsTests
["ACDREAM_DEVTOOLS"] = "1",
["ACDREAM_UNCAPPED_RENDER"] = "1",
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
+ ["ACDREAM_DUMP_SKY"] = "1",
["ACDREAM_NO_AUDIO"] = "1",
["ACDREAM_ENABLE_SKY_PES"] = "1",
["ACDREAM_DUMP_SCENERY_Z"] = "1",
@@ -354,6 +375,7 @@ public sealed class RuntimeOptionsTests
Assert.True(allOn.DevTools);
Assert.True(allOn.UncappedRendering);
Assert.True(allOn.DumpMoveTruth);
+ Assert.True(allOn.DumpSky);
Assert.True(allOn.NoAudio);
Assert.True(allOn.EnableSkyPesDebug);
Assert.True(allOn.DumpSceneryZ);
diff --git a/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs b/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs
index e78d9e11..9135e115 100644
--- a/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs
@@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.World;
+using AcDream.Runtime.World;
namespace AcDream.App.Tests.World;
@@ -57,25 +58,23 @@ public sealed class WorldEnvironmentControllerTests
}
[Fact]
- public void Initialize_WithoutGameTimeRestoresDocumentedFallbackOrigin()
+ public void Initialize_WithoutGameTimeUsesInstanceFallbackOrigin()
{
- double previous = DerethDateTime.OriginOffsetTicks;
- try
- {
- DerethDateTime.SetOriginOffsetFromDat(3600.0);
- var controller = CreateController([]);
+ var first = CreateController([]);
+ var second = CreateController([]);
- controller.Initialize(SingleGroupSky("Sunny", begin: 0f), zeroTimeOfYear: null);
+ first.Initialize(
+ SingleGroupSky("Sunny", begin: 0f),
+ zeroTimeOfYear: 3600.0);
+ second.Initialize(
+ SingleGroupSky("Sunny", begin: 0f),
+ zeroTimeOfYear: null);
- Assert.Equal(
- DerethDateTime.DayFractionOriginOffsetTicks,
- DerethDateTime.OriginOffsetTicks);
- Assert.InRange(controller.DayFraction, 0.499f, 0.501f);
- }
- finally
- {
- DerethDateTime.SetOriginOffsetFromDat(previous);
- }
+ Assert.Equal(3600.0, first.WorldTime.Calendar.OriginOffsetTicks);
+ Assert.Equal(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ second.WorldTime.Calendar.OriginOffsetTicks);
+ Assert.InRange(second.DayFraction, 0.499f, 0.501f);
}
[Theory]
@@ -143,8 +142,8 @@ public sealed class WorldEnvironmentControllerTests
private static WorldEnvironmentController CreateController(List log) =>
new(
- new WorldTimeService(SkyStateProvider.Default()),
- new WeatherSystem(),
+ new RuntimeWorldEnvironmentState(log: log.Add),
+ forcedDayGroupIndex: null,
log.Add);
private static LoadedSkyDesc SingleGroupSky(string name, float begin)
@@ -180,8 +179,8 @@ public sealed class WorldEnvironmentControllerTests
line.Contains("→ DayGroup[", StringComparison.Ordinal);
}
-[CollectionDefinition(Name, DisableParallelization = true)]
+[CollectionDefinition(Name)]
public sealed class WorldEnvironmentControllerCollection
{
- public const string Name = "World environment process-global calendar";
+ public const string Name = "World environment";
}
diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
index c6659179..0a0c87cf 100644
--- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
+++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
@@ -115,17 +115,10 @@ public sealed class DerethDateTimeTests
// 291,411,660 - 3600 = 291,408,060
// Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow,
// day 24 1-indexed.
- DerethDateTime.SetOriginOffsetFromDat(3600.0);
- try
- {
- var cal = DerethDateTime.ToCalendar(291_408_060.0);
- Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year);
- Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month);
- Assert.Equal(24, cal.Day);
- }
- finally
- {
- DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks);
- }
+ var calendar = new DerethCalendar(3600.0);
+ var cal = calendar.ToCalendar(291_408_060.0);
+ Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year);
+ Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month);
+ Assert.Equal(24, cal.Day);
}
}
diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
index 4ceeddba..e01bf55a 100644
--- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
+++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
@@ -10,6 +10,39 @@ namespace AcDream.Core.Tests.World;
public sealed class SkyDescLoaderTests
{
+ [Theory]
+ [InlineData(20, 10, 0, 16)]
+ [InlineData(20, 10, 83, 5)]
+ [InlineData(20, 116, 83, 18)]
+ [InlineData(20, 127, 359, 4)]
+ public void DayGroupSelector_MatchesNamedRetailGoldenValues(
+ int count,
+ int absoluteYear,
+ int dayOfYear,
+ int expected)
+ {
+ Assert.Equal(
+ expected,
+ SkyDayGroupSelector.SelectIndex(
+ count,
+ absoluteYear,
+ daysPerYear: 360,
+ dayOfYear));
+ }
+
+ [Fact]
+ public void DayGroupSelector_ForcedIndexIsExplicitInput()
+ {
+ Assert.Equal(
+ 7,
+ SkyDayGroupSelector.SelectIndex(
+ 20,
+ absoluteYear: 10,
+ daysPerYear: 360,
+ dayOfYear: 0,
+ forcedIndex: 7));
+ }
+
///
/// Hand-build a Region with a minimal sky descriptor to feed the
/// loader without needing real dat bytes. The LoadFromRegion
diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
index f05f64a5..4e3db2f4 100644
--- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
+++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
@@ -29,10 +29,11 @@ public sealed class WorldTimeDebugTests
// fraction 1/16: solve (t + 7/16*D) mod D = 1/16*D
// → t = (1/16 - 7/16) * D mod D = -6/16 * D mod D = 10/16 * D.
double targetFraction = 1.0 / 16.0; // Darktide-and-Half
- double syncTick = targetFraction * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks;
+ var service = new WorldTimeService(SkyStateProvider.Default());
+ double syncTick = targetFraction * DerethDateTime.DayTicks
+ - service.Calendar.OriginOffsetTicks;
while (syncTick < 0) syncTick += DerethDateTime.DayTicks;
- var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(syncTick);
service.SetDebugTime(0.5f);
service.ClearDebugTime();
diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
index d6695e08..844de703 100644
--- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
+++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
@@ -182,6 +182,7 @@ public sealed class GameRuntimeContractTests
InteractionSourceObjectId: 0u,
InteractionTransactions: default),
default,
+ default,
default);
recorder.AddCheckpoint(stamp, checkpoint);
diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldEnvironmentStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldEnvironmentStateTests.cs
new file mode 100644
index 00000000..6169fecd
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldEnvironmentStateTests.cs
@@ -0,0 +1,196 @@
+using System.Numerics;
+using AcDream.Core.World;
+using AcDream.Runtime.World;
+
+namespace AcDream.Runtime.Tests.World;
+
+public sealed class RuntimeWorldEnvironmentStateTests
+{
+ [Fact]
+ public void Initialize_OwnsRetailDayGroupAndOfflineNoon()
+ {
+ List log = [];
+ var state = new RuntimeWorldEnvironmentState(
+ new ManualTimeProvider(),
+ log.Add);
+
+ state.Initialize(Definition(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ ("Sunny", Sky(0f))));
+
+ Assert.True(state.IsInitialized);
+ Assert.Equal(0, state.ActiveDayGroupIndex);
+ Assert.Equal(WeatherKind.Clear, state.Weather.Kind);
+ Assert.InRange(state.WorldTime.DayFraction, 0.499, 0.501);
+ Assert.Contains(
+ log,
+ value => value.Contains(
+ "SkyDesc.TickSize=0.8",
+ StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void TwoInstances_IsolateCalendarClockWeatherAndDebugState()
+ {
+ var time = new ManualTimeProvider();
+ var first = new RuntimeWorldEnvironmentState(time);
+ var second = new RuntimeWorldEnvironmentState(time);
+ first.Initialize(Definition(3600.0, ("Sunny", Sky(0f))));
+ second.Initialize(Definition(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ ("Cloudy", Sky(0f))));
+
+ first.SynchronizeFromServer(0.0);
+ second.SynchronizeFromServer(0.0);
+ _ = first.ApplyAdminEnvirons(0x01u);
+ _ = first.CycleTimeOfDay();
+
+ Assert.Equal(3600.0, first.WorldTime.Calendar.OriginOffsetTicks);
+ Assert.Equal(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ second.WorldTime.Calendar.OriginOffsetTicks);
+ Assert.Equal(0f, first.WorldTime.DayFraction);
+ Assert.InRange(
+ second.WorldTime.DayFraction,
+ 7.0 / 16.0 - 0.001,
+ 7.0 / 16.0 + 0.001);
+ Assert.Equal(EnvironOverride.RedFog, first.Weather.Override);
+ Assert.Equal(EnvironOverride.None, second.Weather.Override);
+ Assert.Equal(WeatherKind.Clear, first.Weather.Kind);
+ Assert.Equal(WeatherKind.Overcast, second.Weather.Kind);
+ }
+
+ [Fact]
+ public void SynchronizeFromServer_ClearsDebugOverrideAndAdvancesClock()
+ {
+ var time = new ManualTimeProvider();
+ var state = new RuntimeWorldEnvironmentState(time);
+ state.Initialize(Definition(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ ("Sunny", Sky(0f))));
+ _ = state.CycleTimeOfDay();
+
+ state.SynchronizeFromServer(0.0);
+ time.Advance(TimeSpan.FromSeconds(12));
+
+ Assert.Equal(12.0, state.WorldTime.NowTicks, 5);
+ Assert.NotEqual(0.0, state.WorldTime.DayFraction);
+ }
+
+ [Theory]
+ [InlineData(0x00u, EnvironOverride.None)]
+ [InlineData(0x01u, EnvironOverride.RedFog)]
+ [InlineData(0x06u, EnvironOverride.BlackFog2)]
+ public void ApplyAdminEnvirons_FogIsCanonicalRuntimeState(
+ uint raw,
+ EnvironOverride expected)
+ {
+ var state = Initialized();
+
+ RuntimeEnvironmentEffect effect = state.ApplyAdminEnvirons(raw);
+
+ Assert.Equal(RuntimeEnvironmentEffectKind.FogOverride, effect.Kind);
+ Assert.Equal(expected, effect.FogOverride);
+ Assert.Equal(expected, state.Snapshot.EnvironOverride);
+ }
+
+ [Fact]
+ public void ApplyAdminEnvirons_SoundDoesNotMutateFogState()
+ {
+ var state = Initialized();
+ _ = state.ApplyAdminEnvirons(0x04u);
+
+ RuntimeEnvironmentEffect effect = state.ApplyAdminEnvirons(0x78u);
+
+ Assert.Equal(RuntimeEnvironmentEffectKind.SoundCue, effect.Kind);
+ Assert.Equal(RuntimeEnvironmentSoundCue.Thunder3, effect.SoundCue);
+ Assert.Equal(EnvironOverride.GreenFog, state.Weather.Override);
+ }
+
+ [Fact]
+ public void ForcedDayGroup_IsDefinitionScoped()
+ {
+ var state = new RuntimeWorldEnvironmentState(
+ new ManualTimeProvider());
+ state.Initialize(new RuntimeWorldEnvironmentDefinition(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ sourceTickSize: 0.8,
+ lightTickSize: 0.2,
+ [
+ Group("Sunny", Sky(0f)),
+ Group("Cloudy", Sky(0f)),
+ ],
+ forcedDayGroupIndex: 1));
+
+ Assert.Equal(1, state.ActiveDayGroupIndex);
+ Assert.Equal(WeatherKind.Overcast, state.Weather.Kind);
+ }
+
+ [Fact]
+ public void Initialize_IsOneShot()
+ {
+ var state = Initialized();
+
+ InvalidOperationException error =
+ Assert.Throws(
+ () => state.Initialize(Definition(
+ 3600.0,
+ ("Cloudy", Sky(0f)))));
+
+ Assert.Contains("one-shot", error.Message, StringComparison.Ordinal);
+ Assert.Equal(0, state.ActiveDayGroupIndex);
+ }
+
+ private static RuntimeWorldEnvironmentState Initialized()
+ {
+ var state = new RuntimeWorldEnvironmentState(
+ new ManualTimeProvider());
+ state.Initialize(Definition(
+ DerethDateTime.DayFractionOriginOffsetTicks,
+ ("Sunny", Sky(0f))));
+ return state;
+ }
+
+ private static RuntimeWorldEnvironmentDefinition Definition(
+ double origin,
+ params (string Name, SkyStateProvider Sky)[] groups) =>
+ new(
+ origin,
+ sourceTickSize: 0.8,
+ lightTickSize: 0.2,
+ groups.Select(value => Group(value.Name, value.Sky)));
+
+ private static RuntimeWorldDayGroupDefinition Group(
+ string name,
+ SkyStateProvider sky) =>
+ new(
+ name,
+ ChanceOfOccur: 100f,
+ SkyObjectCount: 1,
+ sky);
+
+ private static SkyStateProvider Sky(float begin) =>
+ new(
+ [
+ new SkyKeyframe(
+ Begin: begin,
+ SunHeadingDeg: 0f,
+ SunPitchDeg: 90f,
+ DirColor: Vector3.One,
+ DirBright: 1f,
+ AmbColor: Vector3.One,
+ AmbBright: 1f,
+ FogColor: Vector3.Zero,
+ FogDensity: 0f),
+ ]);
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now =
+ new(2026, 7, 26, 0, 0, 0, TimeSpan.Zero);
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan elapsed) => _now += elapsed;
+ }
+}