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
+ }
+ }
}