using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.World; /// /// Fog modes mirroring retail's D3DFOGMODE. Retail only ever uses /// and ; the Exp variants are /// supported by the dat schema but never appear in shipped data. See r12 /// §5 and SkyTimeOfDay.WorldFog (dat uint). /// public enum FogMode { Off = 0, Linear = 1, Exp = 2, Exp2 = 3, } /// /// One sky keyframe — the full lighting + fog state for a specific /// day-fraction. Multiple keyframes across [0, 1) interpolate /// linearly (with angular-shortest-arc wrap on sun direction) to produce /// the current sky state. /// /// /// Retail's SkyTimeOfDay dat struct carries this exact data plus /// references to sky objects (sun mesh, moon mesh, cloud layer) which /// belong to the renderer. This record exposes the shader-relevant /// subset — sun direction, sun color, ambient color, linear fog. See /// references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs /// and r12 §4 + §5. /// /// /// /// Colors are stored RAW (NOT pre-multiplied by brightness) in /// / with the brightness /// scalars in / . Retail's /// SkyDesc::GetLighting at 0x00500ac9 (decomp lines /// 261317-261331) lerps each channel separately and lerps brightness /// separately, then multiplies post-lerp. Lerping the pre-multiplied /// product gives mathematically different results when both color and /// brightness change between adjacent keyframes — the cause of subtle /// brightness discrepancies vs retail observed in dual-client /// comparisons (Issue #3 visual sub-bug, 2026-04-27). /// /// /// The computed properties and /// return the post-multiplied product, so /// downstream shader uniform plumbing (sky.vert / mesh.vert / /// SceneLightingUbo) is unchanged. /// /// public readonly record struct SkyKeyframe( float Begin, // [0, 1] day-fraction this keyframe kicks in float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W) float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith) Vector3 DirColor, // RGB linear, RAW (NOT × DirBright) float DirBright, // sun brightness multiplier Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright) float AmbBright, // ambient brightness multiplier Vector3 FogColor, float FogDensity, // retained for tests; derive from FogStart/End float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm) float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm) FogMode FogMode = FogMode.Linear) { /// /// Final directional sun color used by the shader = /// × . Computed property /// so the storage stays as separate channels (for retail-faithful /// keyframe interpolation) while the shader interface stays simple. /// public Vector3 SunColor => DirColor * DirBright; /// /// Final ambient color used by the shader = /// × . See /// for the rationale. /// public Vector3 AmbientColor => AmbColor * AmbBright; } /// /// Sky keyframe interpolator — given a day fraction in [0, 1), returns /// the blended lighting state between the surrounding keyframes. /// /// /// Math (r12 §4): /// /// /// Pick the two keyframes bracketing t: k1 = last /// keyframe with Begin <= t, k2 = next keyframe /// (wraps: if k1 is last, k2 is first). /// /// /// Local blend u = (t - k1.Begin) / (k2.Begin - k1.Begin) /// with wrap handling. /// /// /// Lerp every vector component; use shortest-arc lerp for the sun /// heading so k1=350° → k2=10° doesn't sweep backwards across the sky. /// /// /// /// public sealed class SkyStateProvider { private readonly List _keyframes; public SkyStateProvider(IReadOnlyList keyframes) { if (keyframes is null || keyframes.Count == 0) throw new ArgumentException("At least one keyframe required", nameof(keyframes)); // Sort by Begin so the walk is deterministic regardless of input order. var sorted = new List(keyframes); sorted.Sort((a, b) => a.Begin.CompareTo(b.Begin)); _keyframes = sorted; } public int KeyframeCount => _keyframes.Count; public IReadOnlyList Keyframes => _keyframes; /// /// Default keyframe set based on retail observations — sunrise at 6am, /// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't /// available yet or the player is in a region whose Region dat /// doesn't override it. /// /// /// Fog values approximate retail clear-weather defaults: ~80m..~350m /// linear fog with color matching the horizon band so mountains at /// distance fade into the sky instead of popping at the clip plane. /// See r12 §5.1. /// /// public static SkyStateProvider Default() { // Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk. return new SkyStateProvider(new[] { // Default factory: brightness scalars are 1.0 here — the colors // ARE the final intended values. Live Dereth keyframes loaded // from the dat have separate non-1.0 DirBright/AmbBright values // and the renderer multiplies them post-lerp. new SkyKeyframe( Begin: 0.0f, SunHeadingDeg: 0f, // below horizon (north) SunPitchDeg: -30f, DirColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue DirBright: 1.0f, AmbColor: new Vector3(0.05f, 0.05f, 0.12f), AmbBright: 1.0f, FogColor: new Vector3(0.02f, 0.02f, 0.05f), FogDensity: 0.004f, FogStart: 30f, FogEnd: 180f, FogMode: FogMode.Linear), new SkyKeyframe( Begin: 0.25f, SunHeadingDeg: 90f, // east at dawn SunPitchDeg: 0f, DirColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm DirBright: 1.0f, AmbColor: new Vector3(0.4f, 0.35f, 0.3f), AmbBright: 1.0f, FogColor: new Vector3(0.8f, 0.55f, 0.4f), FogDensity: 0.002f, FogStart: 60f, FogEnd: 260f, FogMode: FogMode.Linear), new SkyKeyframe( Begin: 0.5f, SunHeadingDeg: 180f, // south at noon SunPitchDeg: 70f, DirColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish DirBright: 1.0f, AmbColor: new Vector3(0.5f, 0.5f, 0.55f), AmbBright: 1.0f, FogColor: new Vector3(0.7f, 0.75f, 0.85f), FogDensity: 0.0008f, FogStart: 120f, FogEnd: 500f, FogMode: FogMode.Linear), new SkyKeyframe( Begin: 0.75f, SunHeadingDeg: 270f, // west at dusk SunPitchDeg: 0f, DirColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red DirBright: 1.0f, AmbColor: new Vector3(0.35f, 0.25f, 0.25f), AmbBright: 1.0f, FogColor: new Vector3(0.85f, 0.45f, 0.35f), FogDensity: 0.002f, FogStart: 60f, FogEnd: 260f, FogMode: FogMode.Linear), }); } /// /// Current interpolated sky state at day fraction . /// Wraps correctly across the day boundary (1.0 → 0.0). /// public SkyKeyframe Interpolate(float t) { t = (float)(t - Math.Floor(t)); // wrap to [0, 1) // Find k1: last keyframe with Begin <= t. int k1Index = _keyframes.Count - 1; for (int i = 0; i < _keyframes.Count; i++) { if (_keyframes[i].Begin <= t) k1Index = i; else break; } int k2Index = (k1Index + 1) % _keyframes.Count; var k1 = _keyframes[k1Index]; var k2 = _keyframes[k2Index]; // Compute blend weight, handling wrap (k1 is last, k2 is first). float k1Begin = k1.Begin; float k2Begin = k2.Begin; if (k2Begin <= k1Begin) k2Begin += 1.0f; // unroll wrap float tWrapped = t; if (tWrapped < k1Begin) tWrapped += 1.0f; float span = Math.Max(1e-6f, k2Begin - k1Begin); float u = (tWrapped - k1Begin) / span; u = Math.Clamp(u, 0f, 1f); // Angular lerp for sun heading: pick shortest arc. float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u); // Retail-faithful interpolation: lerp DirColor / DirBright / // AmbColor / AmbBright as SEPARATE CHANNELS, not as the // pre-multiplied product. Mirrors SkyDesc::GetLighting at // 0x00500ac9 (decomp lines 261317-261331). The post-multiplied // SunColor / AmbientColor are computed properties on the result. // Fog mode doesn't interpolate — pick k1's mode (retail uses // Linear everywhere). return new SkyKeyframe( Begin: t, SunHeadingDeg: heading, SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u), DirColor: Vector3.Lerp(k1.DirColor, k2.DirColor, u), DirBright: Lerp(k1.DirBright, k2.DirBright, u), AmbColor: Vector3.Lerp(k1.AmbColor, k2.AmbColor, u), AmbBright: Lerp(k1.AmbBright, k2.AmbBright, u), FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u), FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u), FogStart: Lerp(k1.FogStart, k2.FogStart, u), FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u), FogMode: k1.FogMode); } private static float Lerp(float a, float b, float u) => a + (b - a) * u; /// /// Shortest-arc heading lerp: r12 §4. If a=350 and b=10 /// the lerp walks 20° forward through 0° rather than 340° backward. /// public static float ShortestAngleLerp(float aDeg, float bDeg, float u) { float delta = bDeg - aDeg; while (delta > 180f) delta -= 360f; while (delta < -180f) delta += 360f; return aDeg + delta * u; } /// /// World-space sun direction unit vector pointing FROM the surface /// TOWARDS the sun. Derived from heading + pitch in the returned /// keyframe — shader sunDir uniform should use -this so lighting /// math (N·L) works correctly for the side facing the sun. /// public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf) { float yaw = kf.SunHeadingDeg * (MathF.PI / 180f); float pit = kf.SunPitchDeg * (MathF.PI / 180f); // Heading 0 = +Y (north), +X=east. Pitch up from horizon. float cosP = MathF.Cos(pit); return new Vector3( MathF.Sin(yaw) * cosP, MathF.Cos(yaw) * cosP, MathF.Sin(pit)); } } /// /// Service that turns server-delivered tick counts into live sky state. /// Owns the "current time" clock (seeded from server sync, advanced by /// real-time elapsed between syncs). /// /// /// Supports a debug "time override" (slash-command /time 0.5) that /// forces a specific day fraction regardless of server sync — used for /// screenshots and visual debugging. The override is transient and gets /// cleared on the next TimeSync packet. /// /// public sealed class WorldTimeService { private SkyStateProvider _sky; private double _lastSyncedTicks; private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow; private float? _debugDayFractionOverride; /// /// Rate at which in-game time advances relative to real time. Retail /// default is 1.0 (one wall-clock second = one in-game tick). Server /// config can override via SkyDesc.TickSize; see r12 §1.2. /// public double TickSize { get; set; } = 1.0; public WorldTimeService(SkyStateProvider sky) { _sky = sky ?? throw new ArgumentNullException(nameof(sky)); } /// /// Hot-swap the keyframe source — typically called once at world-load /// time after the Region dat has been parsed by . /// public void SetProvider(SkyStateProvider sky) { _sky = sky ?? throw new ArgumentNullException(nameof(sky)); } /// /// Set the authoritative tick count from a server TimeSync packet. /// Clears any debug override. /// public void SyncFromServer(double serverTicks) { _lastSyncedTicks = serverTicks; _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}"); } } /// /// Debug-only: force a specific day fraction in [0, 1). Overrides /// server-synced time until cleared by /// or . /// public void SetDebugTime(float dayFraction) { _debugDayFractionOverride = dayFraction; } public void ClearDebugTime() => _debugDayFractionOverride = null; /// /// Current ticks at , advanced from the /// last sync by real-time elapsed seconds times . /// public double NowTicks { get { double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds; return _lastSyncedTicks + elapsed * TickSize; } } /// Current day fraction in [0, 1). public double DayFraction { get { if (_debugDayFractionOverride.HasValue) return _debugDayFractionOverride.Value; return DerethDateTime.DayFraction(NowTicks); } } /// Current sky lighting state. public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction); /// Convenience: current sun direction from derived sky state. public Vector3 CurrentSunDirection => SkyStateProvider.SunDirectionFromKeyframe(CurrentSky); public DerethDateTime.Calendar CurrentCalendar => DerethDateTime.ToCalendar(NowTicks); public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks); }