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}");
+ }
}
///