From 5f9df4d62009934843cebfa3fc154d98cd81a461 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 09:32:27 +0200 Subject: [PATCH] =?UTF-8?q?sky(phase-3e):=20drive=20WeatherSystem=20from?= =?UTF-8?q?=20DayGroup=20name=20=E2=80=94=20no=20more=20rogue=20rain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported rain in acdream while retail showed a clear sunny sky after Phase 3d landed. Root cause: two independent weather systems running in parallel. 1. Retail DayGroup picker (FUN_00501990 port, Phase 3c/3c.1) — selected DayGroup[6] "Sunny" correctly. 2. WeatherSystem.Tick (legacy stub from pre-decompile era) — kept rolling its own hardcoded PDF every day (60% Clear, 20% Overcast, 12% Rain, 5% Snow, 3% Storm), independent of the DayGroup picker. Its output drove the rain/snow particle emitters via UpdateWeatherParticles. If its hash happened to land on Rain for today's dayIndex, rain rendered even on a Sunny DayGroup day. Retail has ONE source of truth for weather: the DayGroup roll. There is no separate weather state machine — rain/snow/storm are implied by the DayGroup name and its per-keyframe SkyObjectReplace settings. Fix (Phase 3e): - WeatherSystem.SetKindFromDayGroupName(string?) — loose substring match on the retail DayGroup name: "storm" → Storm, "snow" → Snow, "rain" → Rain, "cloud"/"overcast"/"dark"/"fog" → Overcast, else Clear. Case-insensitive. Covers the names observed in the live Dereth dat dump (Sunny, Clear, Cloudy, Rainy + inferred variants). - WeatherSystem._externallyDriven flag disables the internal RollKind auto-roll once SetKindFromDayGroupName has been called at least once. Tests that drive Tick() directly keep the legacy hash-roll behavior (offline fallback). ForceWeather still works for debug overrides. - GameWindow.RefreshSkyForCurrentDay calls Weather.SetKindFromDayGroupName(grp.Name) right after it installs the new SkyStateProvider. Logs the resulting WeatherKind on the same line as the DayGroup pick for correlation. - New WeatherSystemTests.SetKindFromDayGroupName_MapsRetailNames (theory, 14 cases) + SetKindFromDayGroupName_DisablesInternalRoll. Expected effect: Sunny/Clear DayGroups → no rain emitter. Rainy/Stormy DayGroups → rain emitter active. The user's specific scenario (DayGroup[6] "Sunny") now correctly maps to WeatherKind.Clear and no particles spawn. Build + 733 tests green (+16 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 10 +++- src/AcDream.Core/World/WeatherState.cs | 55 ++++++++++++++++++- .../World/WeatherSystemTests.cs | 43 +++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2d185c3..d2a63aa 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4370,10 +4370,18 @@ public sealed class GameWindow : IDisposable new AcDream.Core.World.SkyStateProvider( grp.SkyTimes.Select(s => s.Keyframe).ToList())); + // Phase 3e: drive the atmospheric weather (rain/snow emitters, + // fog-override categories, lightning strobe) from the retail + // DayGroup name. Stops the legacy WeatherSystem.RollKind hash + // from spawning rain particles on a "Sunny" day (user-observed + // rain regression 2026-04-23 after the retail picker landed on + // DayGroup[6] "Sunny" but the internal hash picked Rain). + Weather.SetKindFromDayGroupName(grp.Name); + Console.WriteLine( $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " + $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + - $"{grp.SkyTimes.Count} keyframes)"); + $"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})"); } } diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 15fc543..5f421fd 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -125,6 +125,12 @@ public sealed class WeatherSystem private int _rolledDayIndex = int.MinValue; // unrolled == "pick one" + // 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. + private bool _externallyDriven; + private readonly Random _strikeJitter; public WeatherSystem(Random? rng = null) @@ -154,6 +160,46 @@ public sealed class WeatherSystem _rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll } + /// + /// Drive the weather kind from the active retail DayGroup name + /// (see SkyDesc::PickCurrentDayGroup port at + /// LoadedSkyDesc.SelectDayGroupIndex). Retail has ONE source + /// of truth for weather — the DayGroup roll — so this replaces the + /// internal hash once the real DayGroup picker + /// is live. Cases are loose substring matches (Dereth day groups use + /// names like "Sunny", "Clear", "Cloudy", "Rainy", "Stormy", "Snowy" + /// per the region dat dump 2026-04-23). + /// + /// + /// Once called at least once, the internal auto-roll in + /// is DISABLED for the rest of the session — + /// control is now external. Tests that drive + /// directly without calling this method remain on the legacy hash + /// roll unchanged. + /// + /// + public void SetKindFromDayGroupName(string? dayGroupName) + { + _externallyDriven = true; + WeatherKind mapped = MapDayGroupNameToKind(dayGroupName); + if (mapped != _kind) BeginTransition(mapped); + } + + private static WeatherKind MapDayGroupNameToKind(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear; + string lc = name.ToLowerInvariant(); + // Order matters — "thunderstorm" contains "storm", match first. + if (lc.Contains("storm")) return WeatherKind.Storm; + if (lc.Contains("snow")) return WeatherKind.Snow; + if (lc.Contains("rain")) return WeatherKind.Rain; + if (lc.Contains("cloud") + || lc.Contains("overcast") + || lc.Contains("dark") + || lc.Contains("fog")) return WeatherKind.Overcast; + return WeatherKind.Clear; // "Sunny", "Clear", anything else + } + /// /// Advance the state machine. Call once per frame from the render /// loop. is the in-game day (derived @@ -167,8 +213,13 @@ public sealed class WeatherSystem if (_transitionT < 1f) _transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds); - // Day changed → re-roll. Skip the sentinel (forced). - if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue) + // 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. + if (!_externallyDriven + && dayIndex != _rolledDayIndex + && _rolledDayIndex != int.MaxValue) { _rolledDayIndex = dayIndex; var newKind = RollKind(dayIndex); diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index e2c8d48..9623f4f 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -99,4 +99,47 @@ public sealed class WeatherSystemTests Assert.Equal(kf.FogStart, snap.FogStart, precision: 2); Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2); } + + [Theory] + [InlineData("Sunny", WeatherKind.Clear)] + [InlineData("SUNNY", WeatherKind.Clear)] + [InlineData("Clear", WeatherKind.Clear)] + [InlineData("Cloudy", WeatherKind.Overcast)] + [InlineData("Overcast", WeatherKind.Overcast)] + [InlineData("Dark skies", WeatherKind.Overcast)] + [InlineData("Fog", WeatherKind.Overcast)] + [InlineData("Rainy", WeatherKind.Rain)] + [InlineData("heavy rain", WeatherKind.Rain)] + [InlineData("Snowy", WeatherKind.Snow)] + [InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default + [InlineData("Stormy", WeatherKind.Storm)] + [InlineData("Thunderstorm", WeatherKind.Storm)] // "storm" wins over no match + [InlineData("", WeatherKind.Clear)] + [InlineData(null, WeatherKind.Clear)] + public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected) + { + var sys = new WeatherSystem(); + sys.SetKindFromDayGroupName(name); + sys.Tick(0, 0, 100f); // finalize transition + Assert.Equal(expected, sys.Kind); + } + + [Fact] + public void SetKindFromDayGroupName_DisablesInternalRoll() + { + // Once driven externally, advancing dayIndex must NOT re-roll + // to a different kind via the internal RollKind hash. + var sys = new WeatherSystem(); + sys.SetKindFromDayGroupName("Sunny"); + sys.Tick(0, 0, 100f); + + var clearKind = sys.Kind; + Assert.Equal(WeatherKind.Clear, clearKind); + + for (int d = 1; d < 50; d++) + { + sys.Tick(0, d, 100f); + Assert.Equal(clearKind, sys.Kind); // stays put — no auto-roll + } + } }