using AcDream.App.Rendering; using AcDream.Core.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. /// 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 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(), log) { } internal WorldEnvironmentController( WorldTimeService worldTime, WeatherSystem weather, Action? log = null) { WorldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); Weather = weather ?? throw new ArgumentNullException(nameof(weather)); _log = log ?? (_ => { }); } public WorldTimeService WorldTime { get; } public WeatherSystem Weather { get; } public DayGroupData? ActiveDayGroup { get; private set; } 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); Initialize( SkyDescLoader.LoadFromRegion(region), region.GameTime?.ZeroTimeOfYear); } internal void Initialize( LoadedSkyDesc? loadedSkyDesc, double? zeroTimeOfYear) { if (_initializationClaimed) { throw new InvalidOperationException( "The world environment is a one-shot GameWindow 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) { // 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(); } WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0); } public void SynchronizeFromServer(double ticks) { WorldTime.SyncFromServer(ticks); RefreshSkyForCurrentDay(); } /// /// 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; } 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})", }; _log( $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } /// /// 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}"; } }