sky(phase-3e): drive WeatherSystem from DayGroup name — no more rogue rain
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) <noreply@anthropic.com>
This commit is contained in:
parent
75ad74e0b3
commit
5f9df4d620
3 changed files with 105 additions and 3 deletions
|
|
@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drive the weather kind from the active retail DayGroup name
|
||||
/// (see <c>SkyDesc::PickCurrentDayGroup</c> port at
|
||||
/// <c>LoadedSkyDesc.SelectDayGroupIndex</c>). Retail has ONE source
|
||||
/// of truth for weather — the DayGroup roll — so this replaces the
|
||||
/// internal <see cref="RollKind"/> 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Once called at least once, the internal auto-roll in
|
||||
/// <see cref="Tick"/> is DISABLED for the rest of the session —
|
||||
/// control is now external. Tests that drive <see cref="Tick"/>
|
||||
/// directly without calling this method remain on the legacy hash
|
||||
/// roll unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the state machine. Call once per frame from the render
|
||||
/// loop. <paramref name="dayIndex"/> 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue