sky(phase-3a): per-Dereth-day weather roll + ACDREAM_DAY_GROUP override

Diagnosed the "retail shows early night, acdream shows early day" time
mismatch: server time sync was working correctly (ticks=291079558 →
dayFraction=0.8546 → EvensongAndHalf, hour 14 of 16). The mismatch was
the hardcoded DayGroup index.

Dereth's SkyDesc carries 20 DayGroups (Sunny / Clear / Cloudy / Storm /
etc), each weighted at ChanceOfOccur=5.0. Retail rolls one per server
day by `ChanceOfOccur` as a PDF (r12 §11). We were always rendering
DayGroup 0 = "Sunny" regardless of day, so at EvensongAndHalf we showed
SkyTime[7]@0.84 — sun still 20° above the western horizon, warm golden
— i.e. pre-sunset rather than the dimmer pre-night appearance retail
shows after rolling a cloudier group.

Fix (Phase 3a):
- LoadedSkyDesc.SelectDayGroupIndex(dayIndex) — deterministic roller:
  SplitMix64 hash of dayIndex → normalize to [0, sumChances) → walk the
  cumulative distribution. Same dayIndex on every client = same weather
  on every client, zero network sync needed.
- LoadedSkyDesc.ActiveDayGroup(ticks) / BuildProviderForDay(ticks) —
  convenience wrappers that compute dayIndex from raw server ticks.
- ACDREAM_DAY_GROUP=<N> env var override. Set to 10 "Clear", 12 "Cloudy",
  etc. for A/B visual verification against retail.
- SyncFromServer gains a [sky-dump] log: `ticks=X dayFraction=Y
  calendar=PY{year} {month} {day} {hour}` so the time-sync state is
  auditable from a single grep.
- GameWindow: tracks _loadedSkyDayIndex + _activeDayGroup. Calls
  RefreshSkyForCurrentDay on every server sync — swaps WorldTime's
  provider + caches the group only when the day index crosses a
  boundary (idempotent within a single day). SkyRenderer.Render now
  consumes _activeDayGroup instead of the legacy DefaultDayGroup.

Observed (this session, local ACE):
  server sent ticks=291079558 → PY106 ColdMeet 10 EvensongAndHalf
  SplitMix64(day 38197) will deterministically pick one of 20 groups.

Build + 717 tests green. Ready for user visual verification with
retail side-by-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 08:27:37 +02:00
parent aa2e20a42e
commit 62e9c6b9ac
3 changed files with 187 additions and 17 deletions

View file

@ -143,15 +143,99 @@ public sealed class LoadedSkyDesc
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
/// <summary>
/// Default day group — currently group 0 per WorldBuilder's
/// <c>SkyboxRenderManager.Render</c>. Weather integration later picks
/// the current day's group by <c>ChanceOfOccur</c>.
/// Pick a <see cref="DayGroupData"/> deterministically for a given
/// in-game day. Matches r12 §11: retail rolls one DayGroup per Derethian
/// day using <see cref="DayGroupData.ChanceOfOccur"/> as a PDF weight,
/// seeded by the day index so all clients in the same server-day see
/// the same weather. Use <see cref="DerethDateTime.DayTicks"/>-floored
/// ticks as <paramref name="dayIndex"/> to stay in sync with the
/// server's <c>PortalYearTicks</c>.
///
/// <para>
/// Honors the <c>ACDREAM_DAY_GROUP</c> 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).
/// </para>
/// </summary>
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;
}
/// <summary>
/// Build a shader-facing <see cref="SkyStateProvider"/> 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.
/// </summary>
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;
}
/// <summary>
/// Legacy accessor — kept for callers that don't yet know the server
/// tick count. Rolls against <c>dayIndex=0</c> (so env-var override
/// works but unforced selection always returns the same group).
/// Prefer <see cref="ActiveDayGroup(double)"/> which syncs to the
/// server clock.
/// </summary>
public DayGroupData? DefaultDayGroup
{
get
{
int idx = SelectDayGroupIndex(0);
return DayGroups.Count > 0 ? DayGroups[idx] : null;
}
}
/// <summary>
/// Build a shader-facing <see cref="SkyStateProvider"/> for the
/// default day group. For retail-faithful day-rolling, rebuild this
/// whenever <see cref="ActiveDayGroup"/> changes (once per Dereth-day).
/// </summary>
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());
}
/// <summary>
/// Build a <see cref="SkyStateProvider"/> for the DayGroup rolled for
/// <paramref name="serverTicks"/>. Call on day rollover to swap the
/// interpolator to the new weather regime.
/// </summary>
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());
}
}
/// <summary>

View file

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