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:
parent
aa2e20a42e
commit
62e9c6b9ac
3 changed files with 187 additions and 17 deletions
|
|
@ -295,6 +295,15 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
|
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
|
||||||
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
|
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
|
// Current rain/snow emitter handles — spawned on weather-kind change
|
||||||
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
|
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
|
||||||
private int _rainEmitterHandle;
|
private int _rainEmitterHandle;
|
||||||
|
|
@ -868,18 +877,16 @@ public sealed class GameWindow : IDisposable
|
||||||
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
|
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
|
||||||
if (_loadedSkyDesc is not null)
|
if (_loadedSkyDesc is not null)
|
||||||
{
|
{
|
||||||
WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider());
|
|
||||||
WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0;
|
WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0;
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
||||||
$"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}");
|
$"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}");
|
||||||
if (_loadedSkyDesc.DefaultDayGroup is not null)
|
|
||||||
{
|
// Initial DayGroup roll using whatever WorldTime currently
|
||||||
Console.WriteLine(
|
// has (either the hardcoded boot seed or a pre-arrived
|
||||||
$"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " +
|
// server sync). RefreshSkyForCurrentDay will re-roll when
|
||||||
$"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " +
|
// ServerTimeUpdated delivers the real ConnectRequest tick.
|
||||||
$"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes");
|
RefreshSkyForCurrentDay();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1024,7 +1031,15 @@ public sealed class GameWindow : IDisposable
|
||||||
// Phase G.1: keep the client's day/night clock in sync with
|
// Phase G.1: keep the client's day/night clock in sync with
|
||||||
// server time. Fires once from ConnectRequest (initial seed)
|
// server time. Fires once from ConnectRequest (initial seed)
|
||||||
// and repeatedly on TimeSync-flagged packets.
|
// 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
|
// Phase F.1-H.1: wire every parsed GameEvent into the right
|
||||||
// Core state class (chat, combat, spellbook, items). After
|
// Core state class (chat, combat, spellbook, items). After
|
||||||
|
|
@ -3612,7 +3627,7 @@ public sealed class GameWindow : IDisposable
|
||||||
if (!cameraInsideCell)
|
if (!cameraInsideCell)
|
||||||
{
|
{
|
||||||
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
|
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
|
||||||
_loadedSkyDesc?.DefaultDayGroup, kf);
|
_activeDayGroup, kf);
|
||||||
}
|
}
|
||||||
|
|
||||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||||
|
|
@ -4280,6 +4295,56 @@ public sealed class GameWindow : IDisposable
|
||||||
ae.CurrFrame = ae.LowFrame;
|
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>
|
/// <summary>
|
||||||
/// Derive the current sun (directional light, slot 0 of the UBO)
|
/// Derive the current sun (directional light, slot 0 of the UBO)
|
||||||
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
|
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
|
||||||
|
|
|
||||||
|
|
@ -143,15 +143,99 @@ public sealed class LoadedSkyDesc
|
||||||
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
|
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default day group — currently group 0 per WorldBuilder's
|
/// Pick a <see cref="DayGroupData"/> deterministically for a given
|
||||||
/// <c>SkyboxRenderManager.Render</c>. Weather integration later picks
|
/// in-game day. Matches r12 §11: retail rolls one DayGroup per Derethian
|
||||||
/// the current day's group by <c>ChanceOfOccur</c>.
|
/// 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>
|
/// </summary>
|
||||||
public DayGroupData? DefaultDayGroup =>
|
public int SelectDayGroupIndex(long dayIndex)
|
||||||
DayGroups.Count > 0 ? DayGroups[0] : null;
|
{
|
||||||
|
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>
|
/// <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>
|
/// </summary>
|
||||||
public SkyStateProvider BuildDefaultProvider()
|
public SkyStateProvider BuildDefaultProvider()
|
||||||
{
|
{
|
||||||
|
|
@ -159,6 +243,18 @@ public sealed class LoadedSkyDesc
|
||||||
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
|
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
|
||||||
return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -289,8 +289,17 @@ public sealed class WorldTimeService
|
||||||
public void SyncFromServer(double serverTicks)
|
public void SyncFromServer(double serverTicks)
|
||||||
{
|
{
|
||||||
_lastSyncedTicks = serverTicks;
|
_lastSyncedTicks = serverTicks;
|
||||||
_lastSyncedWallClockUtc = DateTime.UtcNow;
|
_lastSyncedWallClockUtc = System.DateTime.UtcNow;
|
||||||
_debugDayFractionOverride = null;
|
_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>
|
/// <summary>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue