diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9c1c784..c0014d4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -295,6 +295,15 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc; + // Phase 3a — retail-faithful per-Dereth-day weather roll. The active + // DayGroup is re-picked deterministically whenever the server clock + // crosses a DayTicks boundary. long.MinValue sentinel means + // "no day rolled yet" so the first RefreshSkyForCurrentDay call + // unconditionally installs a provider. See r12 §11 for the roller + // semantics. + private long _loadedSkyDayIndex = long.MinValue; + private AcDream.Core.World.DayGroupData? _activeDayGroup; + // Current rain/snow emitter handles — spawned on weather-kind change // and stopped when the kind leaves Rain/Snow. Non-zero == active. private int _rainEmitterHandle; @@ -868,18 +877,16 @@ public sealed class GameWindow : IDisposable _loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region); if (_loadedSkyDesc is not null) { - WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider()); WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0; Console.WriteLine( $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + $"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}"); - if (_loadedSkyDesc.DefaultDayGroup is not null) - { - Console.WriteLine( - $"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " + - $"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " + - $"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes"); - } + + // Initial DayGroup roll using whatever WorldTime currently + // has (either the hardcoded boot seed or a pre-arrived + // server sync). RefreshSkyForCurrentDay will re-roll when + // ServerTimeUpdated delivers the real ConnectRequest tick. + RefreshSkyForCurrentDay(); } } @@ -1024,7 +1031,15 @@ public sealed class GameWindow : IDisposable // Phase G.1: keep the client's day/night clock in sync with // server time. Fires once from ConnectRequest (initial seed) // and repeatedly on TimeSync-flagged packets. - _liveSession.ServerTimeUpdated += ticks => WorldTime.SyncFromServer(ticks); + // Phase 3a: also re-roll the active DayGroup if the Dereth-day + // index changed — retail rolls one weather preset per server + // day (r12 §11), deterministic from the day index so retail + // and acdream converge without a wire message. + _liveSession.ServerTimeUpdated += ticks => + { + WorldTime.SyncFromServer(ticks); + RefreshSkyForCurrentDay(); + }; // Phase F.1-H.1: wire every parsed GameEvent into the right // Core state class (chat, combat, spellbook, items). After @@ -3612,7 +3627,7 @@ public sealed class GameWindow : IDisposable if (!cameraInsideCell) { _skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, - _loadedSkyDesc?.DefaultDayGroup, kf); + _activeDayGroup, kf); } _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); @@ -4280,6 +4295,56 @@ public sealed class GameWindow : IDisposable ae.CurrFrame = ae.LowFrame; } + /// + /// Phase 3a — re-roll the active DayGroup whenever the current + /// Dereth-day index differs from what we last installed. Idempotent + /// within the same server-day. Swaps both the + /// feeding + /// (for lighting interp) and the cached + /// (for the sky-object render loop). + /// + /// + /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks + /// group N regardless of day index. Useful for A/B testing each + /// weather preset against retail. See + /// + /// for the roller. + /// + /// + private void RefreshSkyForCurrentDay() + { + if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) + return; + + long dayIndex = (long)System.Math.Floor( + WorldTime.NowTicks / AcDream.Core.World.DerethDateTime.DayTicks); + + int idx = _loadedSkyDesc.SelectDayGroupIndex(dayIndex); + var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count + ? _loadedSkyDesc.DayGroups[idx] + : null; + + bool dayChanged = dayIndex != _loadedSkyDayIndex; + bool groupChanged = !ReferenceEquals(grp, _activeDayGroup); + + if (!dayChanged && !groupChanged) return; + + _loadedSkyDayIndex = dayIndex; + _activeDayGroup = grp; + + if (grp is not null && grp.SkyTimes.Count > 0) + { + WorldTime.SetProvider( + new AcDream.Core.World.SkyStateProvider( + grp.SkyTimes.Select(s => s.Keyframe).ToList())); + + Console.WriteLine( + $"sky: day {dayIndex} → DayGroup[{idx}] \"{grp.Name}\" " + + $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + + $"{grp.SkyTimes.Count} keyframes)"); + } + } + /// /// Derive the current sun (directional light, slot 0 of the UBO) /// from the interpolated , diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index e8fb040..4aecffe 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -143,15 +143,99 @@ public sealed class LoadedSkyDesc public IReadOnlyList DayGroups = Array.Empty(); /// - /// Default day group — currently group 0 per WorldBuilder's - /// SkyboxRenderManager.Render. Weather integration later picks - /// the current day's group by ChanceOfOccur. + /// Pick a deterministically for a given + /// in-game day. Matches r12 §11: retail rolls one DayGroup per Derethian + /// day using as a PDF weight, + /// seeded by the day index so all clients in the same server-day see + /// the same weather. Use -floored + /// ticks as to stay in sync with the + /// server's PortalYearTicks. + /// + /// + /// Honors the ACDREAM_DAY_GROUP environment variable as a + /// force-override (useful for visual verification: set it to 12 = + /// "Cloudy" or 10 = "Clear" to confirm the weather-selection + /// hypothesis without running through the roller). + /// /// - public DayGroupData? DefaultDayGroup => - DayGroups.Count > 0 ? DayGroups[0] : null; + public int SelectDayGroupIndex(long dayIndex) + { + if (DayGroups.Count == 0) return 0; + + // Env-var override has absolute priority. + var env = System.Environment.GetEnvironmentVariable("ACDREAM_DAY_GROUP"); + if (int.TryParse(env, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var forced) + && forced >= 0 && forced < DayGroups.Count) + { + return forced; + } + + if (DayGroups.Count == 1) return 0; + + // SplitMix64-style deterministic hash of the day index so all + // clients roll the same weather for the same Dereth-day without + // any network synchronization (r12 §11, "client-local weather + // model"). The constants are the canonical SplitMix64 mixer from + // the JDK's Splittable Random — high avalanche quality. + ulong h = (ulong)dayIndex; + h = (h ^ (h >> 30)) * 0xbf58476d1ce4e5b9UL; + h = (h ^ (h >> 27)) * 0x94d049bb133111ebUL; + h ^= (h >> 31); + + // Sum ChanceOfOccur to get the weighted total. + double total = 0; + for (int i = 0; i < DayGroups.Count; i++) + if (DayGroups[i].ChanceOfOccur > 0) + total += DayGroups[i].ChanceOfOccur; + + // Fall back to uniform if weights are missing / zero. + if (total <= 0) + return (int)(h % (ulong)DayGroups.Count); + + // Pick = h normalized to [0, total), then cumulative-distribution walk. + double pick = (h / (double)ulong.MaxValue) * total; + double cum = 0; + for (int i = 0; i < DayGroups.Count; i++) + { + if (DayGroups[i].ChanceOfOccur > 0) + cum += DayGroups[i].ChanceOfOccur; + if (pick < cum) return i; + } + return DayGroups.Count - 1; + } /// - /// Build a shader-facing for the default day group. + /// Convenience: compute the day index from current server ticks and + /// look up today's rolled DayGroup. Safe to call off the render thread. + /// + public DayGroupData? ActiveDayGroup(double serverTicks) + { + long dayIndex = (long)System.Math.Floor(serverTicks / DerethDateTime.DayTicks); + int idx = SelectDayGroupIndex(dayIndex); + return idx < DayGroups.Count ? DayGroups[idx] : null; + } + + /// + /// Legacy accessor — kept for callers that don't yet know the server + /// tick count. Rolls against dayIndex=0 (so env-var override + /// works but unforced selection always returns the same group). + /// Prefer which syncs to the + /// server clock. + /// + public DayGroupData? DefaultDayGroup + { + get + { + int idx = SelectDayGroupIndex(0); + return DayGroups.Count > 0 ? DayGroups[idx] : null; + } + } + + /// + /// Build a shader-facing for the + /// default day group. For retail-faithful day-rolling, rebuild this + /// whenever changes (once per Dereth-day). /// public SkyStateProvider BuildDefaultProvider() { @@ -159,6 +243,18 @@ public sealed class LoadedSkyDesc if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default(); return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList()); } + + /// + /// Build a for the DayGroup rolled for + /// . Call on day rollover to swap the + /// interpolator to the new weather regime. + /// + public SkyStateProvider BuildProviderForDay(double serverTicks) + { + var grp = ActiveDayGroup(serverTicks); + if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default(); + return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList()); + } } /// diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 48c1e50..94e1ab5 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -289,8 +289,17 @@ public sealed class WorldTimeService public void SyncFromServer(double serverTicks) { _lastSyncedTicks = serverTicks; - _lastSyncedWallClockUtc = DateTime.UtcNow; + _lastSyncedWallClockUtc = System.DateTime.UtcNow; _debugDayFractionOverride = null; + + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") + { + var df = DerethDateTime.DayFraction(serverTicks); + var cal = DerethDateTime.ToCalendar(serverTicks); + System.Console.WriteLine( + $"[sky-dump] SyncFromServer: ticks={serverTicks:F1} dayFraction={df:F4} " + + $"calendar=PY{cal.Year} {cal.Month} {cal.Day} {cal.Hour}"); + } } ///