diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index e4c54f1..51219fd 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -96,47 +96,42 @@ public readonly record struct AtmosphereSnapshot( /// public sealed class WeatherSystem { + /// + /// Kept as a public constant because a handful of callers / tests + /// reference it, but unused internally post-Phase-7: retail does + /// not cross-fade between s (no such + /// concept in the decompile). The SkyTimeOfDay keyframe interp + /// does all time-based fog/light blending directly. + /// 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 + // Flash decay kept so TriggerFlash() is still a usable test hook; + // production code (PlayScript-driven lightning, Phase 6) does NOT + // drive the flash uniform — it spawns particle emitters directly. + private const float FlashDecay = 1f / 0.200f; // 1 / τ sec 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 _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 float _flashAge; private EnvironOverride _override; - private int _rolledDayIndex = int.MinValue; // unrolled == "pick one" + private int _rolledDayIndex = int.MinValue; - // Phase 3e — when GameWindow (via RefreshSkyForCurrentDay) pushes - // the active retail DayGroup name through SetKindFromDayGroupName, - // the internal RollKind hash becomes unused. This flag stops Tick's - // auto-roll so external control can't fight the internal one. + // Phase 3e — when GameWindow pushes the retail DayGroup name via + // SetKindFromDayGroupName, the internal RollKind hash is disabled. private bool _externallyDriven; - private readonly Random _strikeJitter; - public WeatherSystem(Random? rng = null) { - _strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u)); - _nextStrikeInS = 12f; + // The random-seed ctor argument remains for test API compat, + // but no longer drives any production behaviour (Phase 7: the + // Storm-kind random lightning timer was deleted — retail is + // server-driven via PlayScript; see Agents #3 and #5). + _ = rng; } /// Current active weather. @@ -232,15 +227,19 @@ public sealed class WeatherSystem /// 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); + // Phase 7 — dropped: + // - per-Kind cross-fade (_transitionT drove the now-removed + // FogForKind lerp; retail has no such machinery). + // - Storm-kind random lightning timer (retail lightning is + // server-driven via PlayScript per Agent #5 — purely visual + // through the particle system, no UBO flash channel). + // + // What remains: day-index auto-roll as a TEST-ONLY fallback + // (externally driven callers set _externallyDriven=true through + // SetKindFromDayGroupName and this block never fires), plus + // flash-level decay so the TriggerFlash() test hook still works. - // Day changed → re-roll. Skip the sentinel (forced). Also skip - // when weather is externally driven by the retail DayGroup name - // (Phase 3e) — the internal RollKind is a fallback only for - // tests / offline code paths. + // Day changed → re-roll (fallback only — disabled when externally driven). if (!_externallyDriven && dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue) @@ -250,19 +249,9 @@ public sealed class WeatherSystem 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. + // Flash decay — 50ms hold then exponential decay (~200ms τ). + // Production never TriggerFlashes; this exists for tests that + // exercise the UBO channel. if (_flashLevel > 0f) { _flashAge += dtSeconds; @@ -284,40 +273,45 @@ public sealed class WeatherSystem } /// - /// 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. + /// Produce the per-frame atmosphere snapshot from the sky keyframe. + /// + /// + /// Retail-faithful since Phase 7 (2026-04-23): fog is the + /// keyframe's fog, passed through directly (color + distances). + /// The only override channel is set + /// by the server's AdminEnvirons packet (opcode 0xEA60) — + /// in that case we substitute the fog COLOR with the preset tint + /// and keep the keyframe's distances untouched. There is no + /// per- fog manipulation: retail's + /// decompile (Agent #3, 2026-04-23) contains no such logic. The + /// enum is now purely informational — it + /// labels the current sky style for debug overlays but doesn't + /// drive any rendering. + /// /// 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); + // Fog passthrough from the keyframe (retail semantics). + Vector3 fogColor = kf.FogColor; + float fogStart = kf.FogStart; + float fogEnd = kf.FogEnd; - 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. + // AdminEnvirons server override: replace fog COLOR only. + // Keyframe distances unchanged until we find evidence retail + // changes those too (Agent #3 notes the in-game crossfade + // lerps distances via SkyTimeOfDay keyframe interp, NOT via + // AdminEnvirons directly). 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), + Kind: _kind, // informational + Intensity: 1f, // no per-Kind easing in retail FogColor: fogColor, FogStart: fogStart, FogEnd: fogEnd, FogMode: kf.FogMode, - LightningFlash: _flashLevel, + LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests Override: _override); } @@ -329,7 +323,6 @@ public sealed class WeatherSystem { _previousKind = _kind; _kind = newKind; - _transitionT = 0f; } /// @@ -354,23 +347,6 @@ public sealed class WeatherSystem 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), diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index f13a308..20d490b 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -38,19 +38,30 @@ public sealed class WeatherSystemTests } [Fact] - public void Transition_EasesAcrossTenSeconds() + public void Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind() { - // Force Storm, then Clear, sample snapshot fog distance mid-transition. - var sys = new WeatherSystem(); - sys.ForceWeather(WeatherKind.Storm); - sys.Tick(0, 1, 100f); // finalize - + // Phase 7: retail DOES NOT override fog by WeatherKind — Storm + // doesn't produce denser fog, Overcast doesn't shrink distance. + // Every Kind renders the keyframe's fog directly. This test + // replaces the old "Transition_EasesAcrossTenSeconds" which + // codified the invented per-Kind fog behaviour. 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}"); + foreach (var kind in new[] { + WeatherKind.Clear, WeatherKind.Overcast, + WeatherKind.Rain, WeatherKind.Snow, WeatherKind.Storm, + }) + { + var sys = new WeatherSystem(); + sys.ForceWeather(kind); + sys.Tick(0, 1, 100f); // finalize any transition + var snap = sys.Snapshot(in kf); + + Assert.Equal(kind, snap.Kind); + Assert.Equal(kf.FogStart, snap.FogStart, precision: 2); + Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2); + Assert.Equal(kf.FogColor, snap.FogColor); + } } [Fact]