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

@ -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. <c>long.MinValue</c> 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;
}
/// <summary>
/// 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
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
/// <see cref="WorldTime"/> (for lighting interp) and the cached
/// <see cref="_activeDayGroup"/> (for the sky-object render loop).
///
/// <para>
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
/// group N regardless of day index. Useful for A/B testing each
/// weather preset against retail. See
/// <see cref="AcDream.Core.World.LoadedSkyDesc.SelectDayGroupIndex"/>
/// for the roller.
/// </para>
/// </summary>
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)");
}
}
/// <summary>
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,