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 BeginAngleEndAngle (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); + } +}