diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs
new file mode 100644
index 0000000..439aabb
--- /dev/null
+++ b/src/AcDream.Core/World/SkyDescLoader.cs
@@ -0,0 +1,297 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.World;
+
+///
+/// One sky object (celestial mesh) per r12 §2. Each object has:
+///
+/// - A visibility window in day-fraction space.
+/// - A BeginAngle/EndAngle sweep — the arc it traces across the sky during its window.
+/// - A texture-velocity pair for UV scrolling (cloud drift, star twinkle).
+/// - A GfxObj mesh (the actual geometry rendered at large distance).
+///
+///
+///
+/// This is the in-memory mirror of DatReaderWriter.Types.SkyObject
+/// scrubbed of dat-reader dependencies and with a couple of derived
+/// fields pre-computed. The per-keyframe
+/// (r12 §2.3) lives off the owning .
+///
+///
+public sealed class SkyObjectData
+{
+ public float BeginTime;
+ public float EndTime;
+ public float BeginAngle;
+ public float EndAngle;
+ public float TexVelocityX;
+ public float TexVelocityY;
+ public uint GfxObjId;
+ public uint Properties;
+
+ /// Object is visible at day-fraction
+ /// by retail's begin/end semantics (r12 §2). Three cases:
+ ///
+ /// - Begin == End → always visible.
+ /// - Begin < End → daytime arc, visible in [Begin, End].
+ /// - Begin > End → wraps midnight, visible in [Begin, 1) ∪ [0, End].
+ ///
+ public bool IsVisible(float t)
+ {
+ if (BeginTime == EndTime) return true;
+ if (BeginTime < EndTime) return t >= BeginTime && t <= EndTime;
+ // Wrap around midnight.
+ return t >= BeginTime || t <= EndTime;
+ }
+
+ ///
+ /// Arc progress 0..1 through the visibility window; gives the angle
+ /// interpolation for BeginAngle→EndAngle (r12 §2).
+ ///
+ public float AngleProgress(float t)
+ {
+ if (BeginTime == EndTime) return 0f;
+ float duration;
+ float progress;
+ if (BeginTime < EndTime)
+ {
+ duration = EndTime - BeginTime;
+ progress = (t - BeginTime) / duration;
+ }
+ else
+ {
+ duration = (1f - BeginTime) + EndTime;
+ progress = (t >= BeginTime)
+ ? (t - BeginTime) / duration
+ : (t + (1f - BeginTime)) / duration;
+ }
+ return Math.Clamp(progress, 0f, 1f);
+ }
+
+ ///
+ /// Current arc angle in degrees given the day fraction. Linear
+ /// interpolation between and .
+ ///
+ public float CurrentAngle(float t)
+ {
+ if (BeginTime == EndTime) return BeginAngle;
+ return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
+ }
+}
+
+///
+/// Per-keyframe override for one sky object — swap its mesh at dusk,
+/// dim it, or rotate it (r12 §2.3). Indexed by
+/// into the owning day group's SkyObjects list.
+///
+public sealed class SkyObjectReplaceData
+{
+ public uint ObjectIndex;
+ public uint GfxObjId;
+ public float Rotate;
+ public float Transparent;
+ public float Luminosity;
+ public float MaxBright;
+}
+
+///
+/// Full lighting + sky-object-override data for one SkyTimeOfDay
+/// keyframe. Built alongside the the shaders
+/// consume — this form keeps the per-object overrides which the
+/// SkyRenderer needs to swap clouds for overcast keyframes.
+///
+public sealed class DatSkyKeyframeData
+{
+ public SkyKeyframe Keyframe;
+ public IReadOnlyList Replaces = Array.Empty();
+}
+
+///
+/// One DayGroup from retail's Region dat — a self-contained
+/// weather regime. Retail Dereth ships ~3 day groups (clear, overcast,
+/// storm) and the client rolls one per day. r12 §11 describes this.
+///
+public sealed class DayGroupData
+{
+ public float ChanceOfOccur;
+ public string Name = "";
+ public IReadOnlyList SkyObjects = Array.Empty();
+ public IReadOnlyList SkyTimes = Array.Empty();
+}
+
+///
+/// Fully-loaded skybox data pulled from the Region dat (0x13000000).
+/// Has everything the renderer + weather system need to produce a
+/// retail-faithful day/night cycle:
+///
+/// - A ready to drop into .
+/// - A list of day groups for weather picking.
+/// - Calendar constants (DayLength, etc) for cross-checking.
+///
+///
+public sealed class LoadedSkyDesc
+{
+ public double TickSize;
+ public double LightTickSize;
+ public IReadOnlyList DayGroups = Array.Empty();
+
+ ///
+ /// Default day group — currently group 0 per WorldBuilder's
+ /// SkyboxRenderManager.Render. Weather integration later picks
+ /// the current day's group by ChanceOfOccur.
+ ///
+ public DayGroupData? DefaultDayGroup =>
+ DayGroups.Count > 0 ? DayGroups[0] : null;
+
+ ///
+ /// Build a shader-facing for the default day group.
+ ///
+ public SkyStateProvider BuildDefaultProvider()
+ {
+ var grp = DefaultDayGroup;
+ if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
+ return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
+ }
+}
+
+///
+/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
+/// Safe to call off the render thread as long as the underlying
+/// isn't being mutated (acdream's one-shot
+/// startup path already holds the dat lock during Region reads).
+///
+///
+/// Retail stores the entire world's sky + calendar in this single record
+/// — there's only ever one Region. The loader reads the SkyDesc
+/// out of region.SkyInfo, iterates every DayGroup, and converts
+/// each SkyTimeOfDay to our record.
+///
+///
+///
+/// The SunColor / AmbientColor fields store the color × brightness
+/// product so the shader UBO layout can stay a flat vec3 without
+/// extra multiplies per pixel. See r12 §4.
+///
+///
+public static class SkyDescLoader
+{
+ public const uint RegionDatId = 0x13000000u;
+
+ ///
+ /// Load + parse. Returns null if the Region doesn't have
+ /// or the dat is absent.
+ ///
+ public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ var region = dats.Get(RegionDatId);
+ if (region is null) return null;
+ return LoadFromRegion(region);
+ }
+
+ ///
+ /// Convert an in-memory Region object to our domain data.
+ /// Separated so tests can feed hand-built Regions without the dat
+ /// pipeline.
+ ///
+ public static LoadedSkyDesc? LoadFromRegion(Region region)
+ {
+ ArgumentNullException.ThrowIfNull(region);
+ if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
+ return null;
+
+ var sky = region.SkyInfo;
+ var dayGroups = new List(sky.DayGroups.Count);
+
+ foreach (var dg in sky.DayGroups)
+ {
+ var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
+ var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
+
+ dayGroups.Add(new DayGroupData
+ {
+ ChanceOfOccur = dg.ChanceOfOccur,
+ Name = dg.DayName?.ToString() ?? "",
+ SkyObjects = objs,
+ SkyTimes = times,
+ });
+ }
+
+ return new LoadedSkyDesc
+ {
+ TickSize = sky.TickSize,
+ LightTickSize = sky.LightTickSize,
+ DayGroups = dayGroups,
+ };
+ }
+
+ private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
+ {
+ BeginTime = s.BeginTime,
+ EndTime = s.EndTime,
+ BeginAngle = s.BeginAngle,
+ EndAngle = s.EndAngle,
+ TexVelocityX = s.TexVelocityX,
+ TexVelocityY = s.TexVelocityY,
+ GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
+ Properties = s.Properties,
+ };
+
+ private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
+ {
+ var replaces = s.SkyObjReplace.Select(r => new SkyObjectReplaceData
+ {
+ ObjectIndex = r.ObjectIndex,
+ GfxObjId = r.GfxObjId?.DataId ?? 0u,
+ Rotate = r.Rotate,
+ Transparent = r.Transparent,
+ Luminosity = r.Luminosity,
+ MaxBright = r.MaxBright,
+ }).ToList();
+
+ var fogMode = s.WorldFog switch
+ {
+ 1u => FogMode.Linear,
+ 2u => FogMode.Exp,
+ 3u => FogMode.Exp2,
+ _ => FogMode.Off,
+ };
+
+ var kf = new SkyKeyframe(
+ Begin: s.Begin,
+ SunHeadingDeg: s.DirHeading,
+ SunPitchDeg: s.DirPitch,
+ SunColor: ColorToVec3(s.DirColor) * s.DirBright,
+ AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright,
+ FogColor: ColorToVec3(s.WorldFogColor),
+ FogDensity: 0f,
+ FogStart: s.MinWorldFog,
+ FogEnd: s.MaxWorldFog,
+ FogMode: fogMode);
+
+ return new DatSkyKeyframeData
+ {
+ Keyframe = kf,
+ Replaces = replaces,
+ };
+ }
+
+ ///
+ /// stores bytes as B,G,R,A — but the logical
+ /// channel mapping is just "R/G/B in 0..255". Convert to linear
+ /// 0..1 . Alpha is ignored (retail lighting
+ /// doesn't use it).
+ ///
+ public static Vector3 ColorToVec3(ColorARGB? c)
+ {
+ if (c is null) return Vector3.One;
+ return new Vector3(c.Red / 255f, c.Green / 255f, c.Blue / 255f);
+ }
+}
diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs
index e278f53..48c1e50 100644
--- a/src/AcDream.Core/World/SkyState.cs
+++ b/src/AcDream.Core/World/SkyState.cs
@@ -5,25 +5,53 @@ using System.Numerics;
namespace AcDream.Core.World;
///
-/// One sky keyframe — the lighting + fog state for a specific day-fraction.
-/// Multiple keyframes across [0, 1) interpolate linearly (with angular
-/// wrap on sun direction) to produce the current sky state.
+/// 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 class exposes the lighting-relevant
-/// subset — sun direction, sun color, ambient color, fog.
+/// 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 in LINEAR RGB, already pre-multiplied by their brightness
+/// scalar so the shader can plug them straight into the UBO without
+/// knowing about DirBright / AmbBright. Range is loosely
+/// [0, N] — retail dusk tints have channels above 1.0 and the frag
+/// shader clamps after lighting math.
///
///
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 SunColor, // RGB linear, post-brightness multiply
- Vector3 AmbientColor,
+ 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 SunColor, // RGB linear, post-brightness multiply
+ Vector3 AmbientColor, // RGB linear, post-brightness multiply
Vector3 FogColor,
- float FogDensity);
+ 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);
///
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
@@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
/// with wrap handling.
///
/// -
-/// Lerp every vector component; SLERP the sun direction
-/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading
-/// = 350°, k2.Heading = 10°).
+/// Lerp every vector component; use shortest-arc lerp for the sun
+/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
///
///
///
@@ -64,12 +91,20 @@ public sealed class SkyStateProvider
}
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()
{
@@ -83,7 +118,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
- FogDensity: 0.004f),
+ FogDensity: 0.004f,
+ FogStart: 30f,
+ FogEnd: 180f,
+ FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
@@ -91,7 +129,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
- FogDensity: 0.002f),
+ FogDensity: 0.002f,
+ FogStart: 60f,
+ FogEnd: 260f,
+ FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
@@ -99,7 +140,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
- FogDensity: 0.0008f),
+ FogDensity: 0.0008f,
+ FogStart: 120f,
+ FogEnd: 500f,
+ FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
@@ -107,7 +151,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
- FogDensity: 0.002f),
+ FogDensity: 0.002f,
+ FogStart: 60f,
+ FogEnd: 260f,
+ FogMode: FogMode.Linear),
});
}
@@ -145,21 +192,34 @@ public sealed class SkyStateProvider
u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc.
- float h1 = k1.SunHeadingDeg;
- float h2 = k2.SunHeadingDeg;
- float delta = h2 - h1;
- while (delta > 180f) delta -= 360f;
- while (delta < -180f) delta += 360f;
- float heading = h1 + delta * u;
+ float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
+ // Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
return new SkyKeyframe(
- Begin: t,
+ Begin: t,
SunHeadingDeg: heading,
- SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u,
+ SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
- FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * 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;
}
///
@@ -185,42 +245,89 @@ public sealed class SkyStateProvider
/// 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 readonly SkyStateProvider _sky;
+ 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 = DateTime.UtcNow;
+ _debugDayFractionOverride = null;
}
+ ///
+ /// 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.
+ /// last sync by real-time elapsed seconds times .
///
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
- return _lastSyncedTicks + elapsed;
+ return _lastSyncedTicks + elapsed * TickSize;
}
}
/// Current day fraction in [0, 1).
- public double DayFraction => DerethDateTime.DayFraction(NowTicks);
+ 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);
diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs
new file mode 100644
index 0000000..15fc543
--- /dev/null
+++ b/src/AcDream.Core/World/WeatherState.cs
@@ -0,0 +1,309 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.World;
+
+///
+/// Client-local atmospheric regime. Retail AC has no server weather
+/// opcode (r12 §6) — the client picks a state per in-game day via a
+/// deterministic seeded RNG so all players on the same server see the
+/// same weather without any packets. Transitions take ~10 seconds.
+///
+///
+/// The rendering side reads to decide whether to
+/// spawn rain/snow particles and which cloud mesh override to select.
+/// The field lets the fog / particle rate /
+/// cloud-darkness terms ease in and out smoothly rather than popping.
+///
+///
+public enum WeatherKind
+{
+ Clear = 0,
+ Overcast = 1,
+ Rain = 2,
+ Snow = 3,
+ Storm = 4,
+}
+
+///
+/// Server-forced fog override (retail EnvironChangeType). When
+/// the server sends AdminEnvirons (0xEA60) with one of the
+/// non- values, the client overrides its locally-computed
+/// fog color and density with the tint shown below. See r12 §5.2 and
+/// references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs.
+///
+public enum EnvironOverride
+{
+ None = 0x00, // clear override, revert to dat-driven fog
+ RedFog = 0x01,
+ BlueFog = 0x02,
+ WhiteFog = 0x03,
+ GreenFog = 0x04,
+ BlackFog = 0x05,
+ BlackFog2 = 0x06,
+}
+
+///
+/// Full per-frame atmosphere state consumed by the shader + particle
+/// systems. Built by from
+///
+/// - the interpolated ,
+/// - the current ,
+/// - a possibly-active ,
+/// - a transient lightning-flash bump.
+///
+///
+public readonly record struct AtmosphereSnapshot(
+ WeatherKind Kind,
+ float Intensity, // 0..1, eases on state transitions
+ Vector3 FogColor, // final fog color (may be overridden)
+ float FogStart,
+ float FogEnd,
+ FogMode FogMode,
+ float LightningFlash, // 0..1, decays from strike moment
+ EnvironOverride Override);
+
+///
+/// Weather state machine — deterministic per-day RNG picks the weather
+/// kind; a 10-second ease blends fog + particle density between old
+/// and new states. Also owns the lightning-flash timer for storms.
+///
+///
+/// Algorithm (r12 §6.1–6.2):
+///
+/// -
+/// Derive a deterministic Random(dayIndex) per in-game day.
+/// Roll a weighted pick from a table matching retail's rough
+/// 70/15/10/5 distribution (Clear dominates).
+///
+/// -
+/// When the kind changes, store a transitionStart timestamp
+/// and tween from 0 → 1
+/// over .
+///
+/// -
+/// Storm kind only: every 8–30 seconds fire a lightning flash; the
+/// shader reads as
+/// an additive scene bump that decays with a 200 ms time constant.
+///
+/// -
+/// Any server beats the local picks —
+/// stick the override fog color and density in the snapshot until
+/// the server sends .
+///
+///
+///
+///
+public sealed class WeatherSystem
+{
+ public const float TransitionSeconds = 10f;
+
+ // Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms
+ // and decays exponentially with a time constant of ~200ms.
+ private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds
+ private const float FlashPeakHoldS = 0.05f;
+
+ // Retail storm cadence: 8–30 seconds between strikes.
+ private const float StrikeIntervalMinS = 8f;
+ private const float StrikeIntervalMaxS = 30f;
+
+ // Overcast-kind fog feels like ~40–150m retail range (r12 §5.1).
+ private const float OvercastFogStart = 40f;
+ private const float OvercastFogEnd = 150f;
+ private const float StormFogStart = 25f;
+ private const float StormFogEnd = 90f;
+
+ private WeatherKind _kind = WeatherKind.Clear;
+ private WeatherKind _previousKind = WeatherKind.Clear;
+ private float _transitionT; // 0..1 through the cross-fade
+
+ private float _flashLevel;
+ private float _flashAge; // seconds since last strike
+ private float _nextStrikeInS;
+
+ private EnvironOverride _override;
+
+ private int _rolledDayIndex = int.MinValue; // unrolled == "pick one"
+
+ private readonly Random _strikeJitter;
+
+ public WeatherSystem(Random? rng = null)
+ {
+ _strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u));
+ _nextStrikeInS = 12f;
+ }
+
+ /// Current active weather.
+ public WeatherKind Kind => _kind;
+
+ /// Last-known server fog override (sticky between sync packets).
+ public EnvironOverride Override
+ {
+ get => _override;
+ set => _override = value;
+ }
+
+ ///
+ /// Debug / test hook — force a specific weather kind, ignoring the
+ /// per-day roll. Passing returns to
+ /// normal behavior starting on the next day-roll.
+ ///
+ public void ForceWeather(WeatherKind kind)
+ {
+ BeginTransition(kind);
+ _rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
+ }
+
+ ///
+ /// Advance the state machine. Call once per frame from the render
+ /// loop. is the in-game day (derived
+ /// from ); when it changes we re-roll
+ /// the weather kind.
+ ///
+ public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
+ {
+ // Cross-fade progression: transitionT advances toward 1 over
+ // TransitionSeconds. Capped; no further rollover.
+ if (_transitionT < 1f)
+ _transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds);
+
+ // Day changed → re-roll. Skip the sentinel (forced).
+ if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue)
+ {
+ _rolledDayIndex = dayIndex;
+ var newKind = RollKind(dayIndex);
+ if (newKind != _kind) BeginTransition(newKind);
+ }
+
+ // Lightning timer only ticks in Storm kind.
+ if (_kind == WeatherKind.Storm && _override == EnvironOverride.None)
+ {
+ _nextStrikeInS -= dtSeconds;
+ if (_nextStrikeInS <= 0f)
+ {
+ TriggerFlash();
+ _nextStrikeInS = StrikeIntervalMinS
+ + (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS);
+ }
+ }
+
+ // Decay the flash level with a 200ms time constant.
+ if (_flashLevel > 0f)
+ {
+ _flashAge += dtSeconds;
+ if (_flashAge < FlashPeakHoldS)
+ _flashLevel = 1f;
+ else
+ _flashLevel = MathF.Exp(-(_flashAge - FlashPeakHoldS) * FlashDecay);
+ if (_flashLevel < 1e-3f) _flashLevel = 0f;
+ }
+ }
+
+ ///
+ /// Trigger a lightning flash manually (server-forced or test hook).
+ ///
+ public void TriggerFlash()
+ {
+ _flashLevel = 1f;
+ _flashAge = 0f;
+ }
+
+ ///
+ /// Produce the per-frame snapshot consumed by the shader UBO +
+ /// particle emitter spawners. Combines the sky keyframe's fog with
+ /// the weather state's fog overlay, then applies the server
+ /// tint if any.
+ ///
+ public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
+ {
+ // Cross-fade fog distance + color from previous-kind to new-kind.
+ var prev = FogForKind(_previousKind, kf);
+ var curr = FogForKind(_kind, kf);
+
+ float t = _transitionT;
+ var fogColor = Vector3.Lerp(prev.color, curr.color, t);
+ float fogStart = prev.start + (curr.start - prev.start) * t;
+ float fogEnd = prev.end + (curr.end - prev.end) * t;
+
+ // Server environ override wins.
+ if (_override != EnvironOverride.None)
+ {
+ fogColor = EnvironOverrideColor(_override);
+ fogStart = 15f;
+ fogEnd = 80f; // Dense override fog
+ }
+
+ float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
+
+ return new AtmosphereSnapshot(
+ Kind: _kind,
+ Intensity: Math.Clamp(intensity, 0f, 1f),
+ FogColor: fogColor,
+ FogStart: fogStart,
+ FogEnd: fogEnd,
+ FogMode: kf.FogMode,
+ LightningFlash: _flashLevel,
+ Override: _override);
+ }
+
+ // ----------------------------------------------------------------
+ // Internal machinery
+ // ----------------------------------------------------------------
+
+ private void BeginTransition(WeatherKind newKind)
+ {
+ _previousKind = _kind;
+ _kind = newKind;
+ _transitionT = 0f;
+ }
+
+ ///
+ /// Deterministic per-day weighted roll. Seeded with
+ /// alone so every client running the same day sees the same weather —
+ /// retail's mechanism for "synchronized weather without any packets"
+ /// (r12 §6.1).
+ ///
+ private static WeatherKind RollKind(int dayIndex)
+ {
+ // Mix the day index so consecutive days aren't adjacent in PRNG
+ // state space (avoids tiny-seed correlation issues).
+ int seed = unchecked((int)((uint)dayIndex * 0x9E3779B1u));
+ var rng = new Random(seed);
+ double r = rng.NextDouble();
+ // Retail weights (approximate): 60% clear, 20% overcast, 12% rain,
+ // 5% snow, 3% storm. Tuned for "most days are fine, some are bad."
+ if (r < 0.60) return WeatherKind.Clear;
+ if (r < 0.80) return WeatherKind.Overcast;
+ if (r < 0.92) return WeatherKind.Rain;
+ if (r < 0.97) return WeatherKind.Snow;
+ return WeatherKind.Storm;
+ }
+
+ private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf)
+ {
+ return kind switch
+ {
+ WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd),
+ WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f),
+ OvercastFogStart, OvercastFogEnd),
+ WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f),
+ OvercastFogStart, OvercastFogEnd),
+ WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f),
+ OvercastFogStart, OvercastFogEnd * 1.2f),
+ WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f),
+ StormFogStart, StormFogEnd),
+ _ => (kf.FogColor, kf.FogStart, kf.FogEnd),
+ };
+ }
+
+ private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
+ {
+ EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),
+ EnvironOverride.BlueFog => new Vector3(0.08f, 0.15f, 0.60f),
+ EnvironOverride.WhiteFog => new Vector3(0.90f, 0.90f, 0.92f),
+ EnvironOverride.GreenFog => new Vector3(0.08f, 0.55f, 0.12f),
+ EnvironOverride.BlackFog => new Vector3(0.02f, 0.02f, 0.02f),
+ EnvironOverride.BlackFog2 => new Vector3(0.04f, 0.01f, 0.01f),
+ _ => new Vector3(1f, 1f, 1f),
+ };
+}
diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
new file mode 100644
index 0000000..bbb619d
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
@@ -0,0 +1,136 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.World;
+
+public sealed class SkyDescLoaderTests
+{
+ ///
+ /// Hand-build a Region with a minimal sky descriptor to feed the
+ /// loader without needing real dat bytes. The LoadFromRegion
+ /// separator exists precisely for this — keeps the parsing logic
+ /// testable independent of DatCollection.
+ ///
+ private static Region MakeRegion(float dirBright, byte rBgrOrder)
+ {
+ var region = new Region();
+ region.PartsMask = PartsMask.HasSkyInfo;
+
+ var sky = new SkyDesc
+ {
+ TickSize = 1.0,
+ LightTickSize = 2.0,
+ };
+
+ var dg = new DayGroup
+ {
+ ChanceOfOccur = 1.0f,
+ };
+
+ var time = new SkyTimeOfDay
+ {
+ Begin = 0.5f,
+ DirBright = dirBright,
+ DirHeading = 180f,
+ DirPitch = 70f,
+ DirColor = new ColorARGB { Blue = 0, Green = 0, Red = rBgrOrder, Alpha = 255 },
+ AmbBright = 0.4f,
+ AmbColor = new ColorARGB { Blue = 100, Green = 100, Red = 100, Alpha = 255 },
+ MinWorldFog = 120f,
+ MaxWorldFog = 400f,
+ WorldFogColor = new ColorARGB { Blue = 50, Green = 50, Red = 50, Alpha = 255 },
+ WorldFog = 1, // Linear
+ };
+
+ dg.SkyTime.Add(time);
+ sky.DayGroups.Add(dg);
+ region.SkyInfo = sky;
+
+ return region;
+ }
+
+ [Fact]
+ public void LoadFromRegion_ConvertsFogFields()
+ {
+ var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
+ var loaded = SkyDescLoader.LoadFromRegion(region);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(1.0, loaded!.TickSize);
+ Assert.Single(loaded.DayGroups);
+ var grp = loaded.DayGroups[0];
+ Assert.Single(grp.SkyTimes);
+
+ var kf = grp.SkyTimes[0].Keyframe;
+ Assert.Equal(120f, kf.FogStart);
+ Assert.Equal(400f, kf.FogEnd);
+ Assert.Equal(FogMode.Linear, kf.FogMode);
+ }
+
+ [Fact]
+ public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness()
+ {
+ var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
+ var loaded = SkyDescLoader.LoadFromRegion(region);
+ Assert.NotNull(loaded);
+
+ var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
+ // R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176
+ Assert.InRange(kf.SunColor.X, 1.17f, 1.19f);
+ }
+
+ [Fact]
+ public void LoadFromRegion_NoSkyInfo_ReturnsNull()
+ {
+ var region = new Region { PartsMask = 0 };
+ Assert.Null(SkyDescLoader.LoadFromRegion(region));
+ }
+
+ [Fact]
+ public void BuildDefaultProvider_FromDatKeyframes_SupportsInterpolation()
+ {
+ var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
+ var loaded = SkyDescLoader.LoadFromRegion(region)!;
+ var provider = loaded.BuildDefaultProvider();
+
+ // Exactly one keyframe: interpolation at any t returns it.
+ var s = provider.Interpolate(0.1f);
+ Assert.InRange(s.SunColor.X, 0.99f, 1.01f);
+ }
+
+ [Fact]
+ public void SkyObjectData_IsVisible_HandlesWrap()
+ {
+ var obj = new SkyObjectData
+ {
+ BeginTime = 0.9f, // wraps across midnight
+ EndTime = 0.1f,
+ };
+
+ Assert.True(obj.IsVisible(0.95f)); // near end of day
+ Assert.True(obj.IsVisible(0.05f)); // just after midnight
+ Assert.False(obj.IsVisible(0.5f)); // mid-day (not visible)
+ }
+
+ [Fact]
+ public void SkyObjectData_CurrentAngle_LerpsAcrossWindow()
+ {
+ var obj = new SkyObjectData
+ {
+ BeginTime = 0.25f,
+ EndTime = 0.75f,
+ BeginAngle = 0f,
+ EndAngle = 180f,
+ };
+
+ // Middle of the window → 90°.
+ Assert.Equal(90f, obj.CurrentAngle(0.5f), precision: 2);
+ // At begin → begin angle.
+ Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
new file mode 100644
index 0000000..e2c8d48
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Numerics;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.World;
+
+public sealed class WeatherSystemTests
+{
+ [Fact]
+ public void Roll_Deterministic_ForSameDayIndex()
+ {
+ var a = new WeatherSystem();
+ var b = new WeatherSystem();
+
+ for (int d = 0; d < 100; d++)
+ {
+ a.Tick(0, d, 100f); // big dt to finish any transition
+ b.Tick(0, d, 100f);
+ Assert.Equal(a.Kind, b.Kind);
+ }
+ }
+
+ [Fact]
+ public void Roll_WeightsDominatedByClear()
+ {
+ // Clear should cover ~60% of the distribution. Sample many days
+ // and check the clear fraction is in a reasonable band.
+ var sys = new WeatherSystem();
+ int clear = 0;
+ for (int d = 0; d < 1000; d++)
+ {
+ sys.Tick(0, d, 100f);
+ if (sys.Kind == WeatherKind.Clear) clear++;
+ }
+ double frac = clear / 1000.0;
+ Assert.InRange(frac, 0.45, 0.75);
+ }
+
+ [Fact]
+ public void Transition_EasesAcrossTenSeconds()
+ {
+ // Force Storm, then Clear, sample snapshot fog distance mid-transition.
+ var sys = new WeatherSystem();
+ sys.ForceWeather(WeatherKind.Storm);
+ sys.Tick(0, 1, 100f); // finalize
+
+ var kf = SkyStateProvider.Default().Interpolate(0.5f);
+ var stormFog = sys.Snapshot(in kf);
+ Assert.Equal(WeatherKind.Storm, stormFog.Kind);
+
+ // Snapshot should have a small fog end (storm fog is dense).
+ Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}");
+ }
+
+ [Fact]
+ public void EnvironOverride_ForcesTintedFog()
+ {
+ var sys = new WeatherSystem();
+ sys.Override = EnvironOverride.RedFog;
+
+ var kf = SkyStateProvider.Default().Interpolate(0.5f);
+ var snap = sys.Snapshot(in kf);
+
+ Assert.Equal(EnvironOverride.RedFog, snap.Override);
+ // Red override means the R channel dominates.
+ Assert.True(snap.FogColor.X > snap.FogColor.Y);
+ Assert.True(snap.FogColor.X > snap.FogColor.Z);
+ }
+
+ [Fact]
+ public void Flash_DecaysOverTime()
+ {
+ var sys = new WeatherSystem();
+ sys.TriggerFlash();
+
+ var kf = SkyStateProvider.Default().Interpolate(0.5f);
+ var imm = sys.Snapshot(in kf);
+ Assert.True(imm.LightningFlash > 0.9f);
+
+ // After 1 second the flash should be mostly decayed.
+ sys.Tick(0, 0, 1.0f);
+ var later = sys.Snapshot(in kf);
+ Assert.True(later.LightningFlash < 0.1f,
+ $"lightning flash didn't decay: {later.LightningFlash}");
+ }
+
+ [Fact]
+ public void Snapshot_ClearKind_PassesThroughKeyframeFog()
+ {
+ var sys = new WeatherSystem();
+ sys.ForceWeather(WeatherKind.Clear);
+ sys.Tick(0, 0, 100f); // finish transition
+
+ var kf = SkyStateProvider.Default().Interpolate(0.5f);
+ var snap = sys.Snapshot(in kf);
+
+ // Clear passes the keyframe's fog color + distances through.
+ Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
+ Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
+ }
+}