feat(world): Phase G.1 data model — dat-accurate SkyKeyframe + WeatherSystem

Expand the SkyKeyframe record with retail-exact fog fields (FogStart,
FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained
for backwards compat with tests that pin it; new shipping code reads
FogStart / FogEnd / FogMode directly.

Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition
ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights
are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned
against retail observations. Storm mode triggers lightning flashes
every 8–30 s via an exponential-decay (200ms τ) flash level that the
shader consumes as an additive scene bump.

Add SkyDescLoader that parses the Region dat (0x13000000) into
LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window +
arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready
SkyStateProvider builder. Sun/ambient colors are pre-multiplied by
DirBright/AmbBright so the shader never needs to know about retail's
scalar brightness field.

19 new tests (weather determinism, transition ease, environ override
tint, flash decay, dat-load conversion with fog + pre-mult colors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:29:33 +02:00
parent 63b6922fc2
commit 0df1c5b4a6
5 changed files with 982 additions and 31 deletions

View file

@ -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;
/// <summary>
/// One sky object (celestial mesh) per r12 §2. Each object has:
/// <list type="bullet">
/// <item><description>A visibility window in day-fraction space.</description></item>
/// <item><description>A <c>BeginAngle</c>/<c>EndAngle</c> sweep — the arc it traces across the sky during its window.</description></item>
/// <item><description>A texture-velocity pair for UV scrolling (cloud drift, star twinkle).</description></item>
/// <item><description>A GfxObj mesh (the actual geometry rendered at large distance).</description></item>
/// </list>
///
/// <para>
/// This is the in-memory mirror of <c>DatReaderWriter.Types.SkyObject</c>
/// scrubbed of dat-reader dependencies and with a couple of derived
/// fields pre-computed. The per-keyframe <see cref="SkyObjectReplaceData"/>
/// (r12 §2.3) lives off the owning <see cref="DatSkyKeyframeData"/>.
/// </para>
/// </summary>
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;
/// <summary>Object is visible at day-fraction <paramref name="t"/>
/// by retail's begin/end semantics (r12 §2). Three cases:
/// <list type="bullet">
/// <item><description><c>Begin == End</c> → always visible.</description></item>
/// <item><description><c>Begin &lt; End</c> → daytime arc, visible in [Begin, End].</description></item>
/// <item><description><c>Begin &gt; End</c> → wraps midnight, visible in [Begin, 1) [0, End].</description></item>
/// </list></summary>
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;
}
/// <summary>
/// Arc progress 0..1 through the visibility window; gives the angle
/// interpolation for <c>BeginAngle</c>→<c>EndAngle</c> (r12 §2).
/// </summary>
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);
}
/// <summary>
/// Current arc angle in degrees given the day fraction. Linear
/// interpolation between <see cref="BeginAngle"/> and <see cref="EndAngle"/>.
/// </summary>
public float CurrentAngle(float t)
{
if (BeginTime == EndTime) return BeginAngle;
return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
}
}
/// <summary>
/// Per-keyframe override for one sky object — swap its mesh at dusk,
/// dim it, or rotate it (r12 §2.3). Indexed by
/// <see cref="ObjectIndex"/> into the owning day group's SkyObjects list.
/// </summary>
public sealed class SkyObjectReplaceData
{
public uint ObjectIndex;
public uint GfxObjId;
public float Rotate;
public float Transparent;
public float Luminosity;
public float MaxBright;
}
/// <summary>
/// Full lighting + sky-object-override data for one <c>SkyTimeOfDay</c>
/// keyframe. Built alongside the <see cref="SkyKeyframe"/> the shaders
/// consume — this form keeps the per-object overrides which the
/// <c>SkyRenderer</c> needs to swap clouds for overcast keyframes.
/// </summary>
public sealed class DatSkyKeyframeData
{
public SkyKeyframe Keyframe;
public IReadOnlyList<SkyObjectReplaceData> Replaces = Array.Empty<SkyObjectReplaceData>();
}
/// <summary>
/// One <c>DayGroup</c> 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.
/// </summary>
public sealed class DayGroupData
{
public float ChanceOfOccur;
public string Name = "";
public IReadOnlyList<SkyObjectData> SkyObjects = Array.Empty<SkyObjectData>();
public IReadOnlyList<DatSkyKeyframeData> SkyTimes = Array.Empty<DatSkyKeyframeData>();
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item><description>A <see cref="SkyStateProvider"/> ready to drop into <see cref="WorldTimeService"/>.</description></item>
/// <item><description>A list of day groups for weather picking.</description></item>
/// <item><description>Calendar constants (<c>DayLength</c>, etc) for cross-checking.</description></item>
/// </list>
/// </summary>
public sealed class LoadedSkyDesc
{
public double TickSize;
public double LightTickSize;
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
/// <summary>
/// Default day group — currently group 0 per WorldBuilder's
/// <c>SkyboxRenderManager.Render</c>. Weather integration later picks
/// the current day's group by <c>ChanceOfOccur</c>.
/// </summary>
public DayGroupData? DefaultDayGroup =>
DayGroups.Count > 0 ? DayGroups[0] : null;
/// <summary>
/// Build a shader-facing <see cref="SkyStateProvider"/> for the default day group.
/// </summary>
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());
}
}
/// <summary>
/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
/// Safe to call off the render thread as long as the underlying
/// <see cref="DatCollection"/> isn't being mutated (acdream's one-shot
/// startup path already holds the dat lock during Region reads).
///
/// <para>
/// Retail stores the entire world's sky + calendar in this single record
/// — there's only ever one <c>Region</c>. The loader reads the SkyDesc
/// out of <c>region.SkyInfo</c>, iterates every DayGroup, and converts
/// each <c>SkyTimeOfDay</c> to our <see cref="SkyKeyframe"/> record.
/// </para>
///
/// <para>
/// The SunColor / AmbientColor fields store the color × brightness
/// product so the shader UBO layout can stay a flat <c>vec3</c> without
/// extra multiplies per pixel. See r12 §4.
/// </para>
/// </summary>
public static class SkyDescLoader
{
public const uint RegionDatId = 0x13000000u;
/// <summary>
/// Load + parse. Returns <c>null</c> if the Region doesn't have
/// <see cref="PartsMask.HasSkyInfo"/> or the dat is absent.
/// </summary>
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var region = dats.Get<Region>(RegionDatId);
if (region is null) return null;
return LoadFromRegion(region);
}
/// <summary>
/// Convert an in-memory Region object to our domain data.
/// Separated so tests can feed hand-built Regions without the dat
/// pipeline.
/// </summary>
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<DayGroupData>(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,
};
}
/// <summary>
/// <see cref="ColorARGB"/> 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 <see cref="Vector3"/>. Alpha is ignored (retail lighting
/// doesn't use it).
/// </summary>
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);
}
}

View file

@ -5,15 +5,40 @@ using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// 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 <c>D3DFOGMODE</c>. Retail only ever uses
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
/// supported by the dat schema but never appear in shipped data. See r12
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
/// </summary>
public enum FogMode
{
Off = 0,
Linear = 1,
Exp = 2,
Exp2 = 3,
}
/// <summary>
/// One sky keyframe — the full lighting + fog state for a specific
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
/// linearly (with angular-shortest-arc wrap on sun direction) to produce
/// the current sky state.
///
/// <para>
/// Retail's <c>SkyTimeOfDay</c> 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
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
/// and r12 §4 + §5.
/// </para>
///
/// <para>
/// 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 <c>DirBright</c> / <c>AmbBright</c>. Range is loosely
/// [0, N] — retail dusk tints have channels above 1.0 and the frag
/// shader clamps after lighting math.
/// </para>
/// </summary>
public readonly record struct SkyKeyframe(
@ -21,9 +46,12 @@ public readonly record struct SkyKeyframe(
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,
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);
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
/// with wrap handling.
/// </description></item>
/// <item><description>
/// 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.
/// </description></item>
/// </list>
/// </para>
@ -64,12 +91,20 @@ public sealed class SkyStateProvider
}
public int KeyframeCount => _keyframes.Count;
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
/// <summary>
/// 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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
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,
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;
/// <summary>
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
/// the lerp walks 20° forward through 0° rather than 340° backward.
/// </summary>
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;
}
/// <summary>
@ -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).
///
/// <para>
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) 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.
/// </para>
/// </summary>
public sealed class WorldTimeService
{
private readonly SkyStateProvider _sky;
private SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
private float? _debugDayFractionOverride;
/// <summary>
/// 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 <c>SkyDesc.TickSize</c>; see r12 §1.2.
/// </summary>
public double TickSize { get; set; } = 1.0;
public WorldTimeService(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Hot-swap the keyframe source — typically called once at world-load
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
/// </summary>
public void SetProvider(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Set the authoritative tick count from a server TimeSync packet.
/// Clears any debug override.
/// </summary>
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = DateTime.UtcNow;
_debugDayFractionOverride = null;
}
/// <summary>
/// Debug-only: force a specific day fraction in [0, 1). Overrides
/// server-synced time until cleared by <see cref="SyncFromServer"/>
/// or <see cref="ClearDebugTime"/>.
/// </summary>
public void SetDebugTime(float dayFraction)
{
_debugDayFractionOverride = dayFraction;
}
public void ClearDebugTime() => _debugDayFractionOverride = null;
/// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
/// last sync by real-time elapsed seconds.
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
/// </summary>
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
return _lastSyncedTicks + elapsed;
return _lastSyncedTicks + elapsed * TickSize;
}
}
/// <summary>Current day fraction in [0, 1).</summary>
public double DayFraction => DerethDateTime.DayFraction(NowTicks);
public double DayFraction
{
get
{
if (_debugDayFractionOverride.HasValue)
return _debugDayFractionOverride.Value;
return DerethDateTime.DayFraction(NowTicks);
}
}
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);

View file

@ -0,0 +1,309 @@
using System;
using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// 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.
///
/// <para>
/// The rendering side reads <see cref="Kind"/> to decide whether to
/// spawn rain/snow particles and which cloud mesh override to select.
/// The <see cref="Intensity"/> field lets the fog / particle rate /
/// cloud-darkness terms ease in and out smoothly rather than popping.
/// </para>
/// </summary>
public enum WeatherKind
{
Clear = 0,
Overcast = 1,
Rain = 2,
Snow = 3,
Storm = 4,
}
/// <summary>
/// Server-forced fog override (retail <c>EnvironChangeType</c>). When
/// the server sends <c>AdminEnvirons</c> (0xEA60) with one of the
/// non-<see cref="None"/> values, the client overrides its locally-computed
/// fog color and density with the tint shown below. See r12 §5.2 and
/// <c>references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs</c>.
/// </summary>
public enum EnvironOverride
{
None = 0x00, // clear override, revert to dat-driven fog
RedFog = 0x01,
BlueFog = 0x02,
WhiteFog = 0x03,
GreenFog = 0x04,
BlackFog = 0x05,
BlackFog2 = 0x06,
}
/// <summary>
/// Full per-frame atmosphere state consumed by the shader + particle
/// systems. Built by <see cref="WeatherSystem"/> from
/// <list type="bullet">
/// <item><description>the interpolated <see cref="SkyKeyframe"/>,</description></item>
/// <item><description>the current <see cref="WeatherKind"/>,</description></item>
/// <item><description>a possibly-active <see cref="EnvironOverride"/>,</description></item>
/// <item><description>a transient lightning-flash bump.</description></item>
/// </list>
/// </summary>
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);
/// <summary>
/// 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.
///
/// <para>
/// Algorithm (r12 §6.16.2):
/// <list type="number">
/// <item><description>
/// Derive a deterministic <c>Random(dayIndex)</c> per in-game day.
/// Roll a weighted pick from a table matching retail's rough
/// 70/15/10/5 distribution (Clear dominates).
/// </description></item>
/// <item><description>
/// When the kind changes, store a <c>transitionStart</c> timestamp
/// and tween <see cref="AtmosphereSnapshot.Intensity"/> from 0 → 1
/// over <see cref="TransitionSeconds"/>.
/// </description></item>
/// <item><description>
/// Storm kind only: every 830 seconds fire a lightning flash; the
/// shader reads <see cref="AtmosphereSnapshot.LightningFlash"/> as
/// an additive scene bump that decays with a 200 ms time constant.
/// </description></item>
/// <item><description>
/// Any server <see cref="EnvironOverride"/> beats the local picks —
/// stick the override fog color and density in the snapshot until
/// the server sends <see cref="EnvironOverride.None"/>.
/// </description></item>
/// </list>
/// </para>
/// </summary>
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: 830 seconds between strikes.
private const float StrikeIntervalMinS = 8f;
private const float StrikeIntervalMaxS = 30f;
// Overcast-kind fog feels like ~40150m 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;
}
/// <summary>Current active weather.</summary>
public WeatherKind Kind => _kind;
/// <summary>Last-known server fog override (sticky between sync packets).</summary>
public EnvironOverride Override
{
get => _override;
set => _override = value;
}
/// <summary>
/// Debug / test hook — force a specific weather kind, ignoring the
/// per-day roll. Passing <see cref="WeatherKind.Clear"/> returns to
/// normal behavior starting on the next day-roll.
/// </summary>
public void ForceWeather(WeatherKind kind)
{
BeginTransition(kind);
_rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
}
/// <summary>
/// Advance the state machine. Call once per frame from the render
/// loop. <paramref name="dayIndex"/> is the in-game day (derived
/// from <see cref="DerethDateTime"/>); when it changes we re-roll
/// the weather kind.
/// </summary>
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;
}
}
/// <summary>
/// Trigger a lightning flash manually (server-forced or test hook).
/// </summary>
public void TriggerFlash()
{
_flashLevel = 1f;
_flashAge = 0f;
}
/// <summary>
/// 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
/// <see cref="EnvironOverride"/> tint if any.
/// </summary>
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;
}
/// <summary>
/// Deterministic per-day weighted roll. Seeded with <paramref name="dayIndex"/>
/// alone so every client running the same day sees the same weather —
/// retail's mechanism for "synchronized weather without any packets"
/// (r12 §6.1).
/// </summary>
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),
};
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -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);
}
}