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 the shader feeds into N·L lighting.
/// Retail-faithful magnitude formula:
/// SunColor = DirColor × |sunVec|
/// where sunVec is retail's heading+pitch+brightness vector
/// (see ).
///
///
/// Why |sunVec| instead of DirBright directly: retail's
/// PrimD3DRender::UpdateLightsInternal at 0x0059b57c
/// (decomp line 424118-424119) computes
/// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)
/// from the sun vector SkyDesc::GetLighting built at
/// 0x00500ac9 (decomp lines 261343-261353):
///
/// sunVec.x = sin(H) × DirBright × cos(P)
/// sunVec.y = cos(P) // NOT scaled by DirBright
/// sunVec.z = DirBright × sin(P)
///
/// Because Y is unscaled by DirBright, |sunVec| ≠
/// DirBright in general — it varies with sun pitch and heading.
/// Using DirBright alone underweighted the warm directional
/// term, letting the cool ambient/fog dominate ⇒ acdream rendered
/// blue-white at keyframes where retail looked warm-gray.
///
///
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
///
/// Final ambient color the shader feeds into the per-vertex tint.
/// Retail-faithful magnitude formula:
/// AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)
/// matching SmartBox::SetWorldAmbientLight as called at
/// 0x0050560b (decomp line 267117):
/// SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color)
/// Retail boosts the ambient brightness by 20% of the sun-vector
/// magnitude — i.e. ambient feels warmer when the sun is up, cooler
/// at night. acdream previously used AmbBright alone, which
/// is roughly 44% too dim mid-day ⇒ contributed to the blue-white
/// bias because the warm fill was missing.
///
public Vector3 AmbientColor =>
AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length());
}
///
/// 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;
}
///
/// Retail's raw sun vector (NOT normalized) — the same vector
/// SkyDesc::GetLighting writes at 0x00500ac9
/// (decomp lines 261343, 261352, 261353):
///
/// sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
/// sunVec.y = cos(P_rad) // NOT scaled by DirBright
/// sunVec.z = DirBright × sin(P_rad)
///
/// Y is unscaled by brightness on purpose — that's what makes
/// |sunVec| ≠ DirBright in general (the magnitude varies
/// with pitch/heading, which is the basis for retail's "sun is brighter
/// in some configurations than others" lighting behavior). The shader's
/// uSunDir uniform uses the NORMALIZED vector for N·L; the
/// magnitude feeds intensity and
/// the ambient brightness boost in .
///
public static Vector3 RetailSunVector(SkyKeyframe kf)
{
float h = kf.SunHeadingDeg * (MathF.PI / 180f);
float p = kf.SunPitchDeg * (MathF.PI / 180f);
float cosP = MathF.Cos(p);
float sinP = MathF.Sin(p);
float B = kf.DirBright;
return new Vector3(
MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P)
cosP, // y = cos(P) ← unscaled by B
B * sinP); // z = B × sin(P)
}
///
/// World-space sun direction unit vector pointing FROM the surface
/// TOWARDS the sun, derived from and
/// normalized. The shader sunDir uniform should use this directly
/// (or -this if the lighting math wants the L-vector pointing AT the
/// surface). The previous implementation used standard spherical
/// coordinates (sin(H)cos(P), cos(H)cos(P), sin(P)) which didn't match
/// retail's deliberate Y-decoupled-from-heading convention. Switching
/// to the retail vector subtly tilts the lighting on objects but
/// matches retail's screenshots when both clients view the same scene.
///
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
{
var v = RetailSunVector(kf);
float len = v.Length();
return len > 1e-6f ? v / len : Vector3.UnitZ;
}
}
///
/// 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);
}