From 902076c0a42079596a8a273e9be402776cabf4b0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 16:45:04 +0200 Subject: [PATCH] refactor(runtime): own world environment state --- .../research/2026-04-23-daygroup-selection.md | 2 +- .../Composition/SessionPlayerComposition.cs | 1 + src/AcDream.App/Rendering/GameWindow.cs | 10 +- .../Rendering/RenderFrameDiagnosticSources.cs | 2 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 4 + .../Runtime/CurrentGameRuntimeViewAdapter.cs | 7 + src/AcDream.App/RuntimeOptions.cs | 5 + .../World/WorldEnvironmentController.cs | 250 ++++---------- src/AcDream.Core/World/DerethCalendar.cs | 103 ++++++ src/AcDream.Core/World/DerethDateTime.cs | 27 +- src/AcDream.Core/World/SkyDayGroupSelector.cs | 40 +++ src/AcDream.Core/World/SkyDescLoader.cs | 42 +-- src/AcDream.Core/World/SkyState.cs | 44 ++- src/AcDream.Runtime/GameRuntimeEvents.cs | 4 + src/AcDream.Runtime/GameRuntimeViews.cs | 4 + .../World/RuntimeWorldEnvironmentState.cs | 313 ++++++++++++++++++ .../InteractionUiRuntimeSourcesTests.cs | 2 + .../GameplayInputCommandControllerTests.cs | 2 + .../GameWindowSlice8BoundaryTests.cs | 2 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 4 + .../AcDream.App.Tests/RuntimeOptionsTests.cs | 22 ++ .../World/WorldEnvironmentControllerTests.cs | 39 ++- .../World/DerethDateTimeTests.cs | 17 +- .../World/SkyDescLoaderTests.cs | 33 ++ .../World/WorldTimeDebugTests.cs | 5 +- .../GameRuntimeContractTests.cs | 1 + .../RuntimeWorldEnvironmentStateTests.cs | 196 +++++++++++ 27 files changed, 886 insertions(+), 295 deletions(-) create mode 100644 src/AcDream.Core/World/DerethCalendar.cs create mode 100644 src/AcDream.Core/World/SkyDayGroupSelector.cs create mode 100644 src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs create mode 100644 tests/AcDream.Runtime.Tests/World/RuntimeWorldEnvironmentStateTests.cs 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; + } +}