weather(phase-7): gut WeatherSystem.Snapshot — passthrough keyframe fog

Final pre-decompile-era invention cleanup. Snapshot() now returns
the keyframe's fog (color, start, end) directly in all cases.
AdminEnvirons override replaces fog COLOR only; distances stay at
the keyframe's MinWorldFog/MaxWorldFog.

Removed:
  - FogForKind(kind, kf): the per-WeatherKind fog table with
    invented constants (Overcast 40-150m grey, Storm 25-90m dark,
    Rain 40-150m blue, Snow 60-200m white). Retail has no such
    logic — Agent #3's decompile scan found zero per-Kind fog
    manipulation in chunk_005* / chunk_006*. The SkyTimeOfDay
    keyframe interp (FUN_00501860) does all fog value selection.
  - OvercastFogStart/End, StormFogStart/End constants.
  - Storm-kind random lightning timer + _strikeJitter. Retail's
    lightning is server-driven via PlayScript (Phase 6), not a
    client timer — Agents #3 + #5 both rule this out.
  - Per-Kind cross-fade (_transitionT and TransitionSeconds-based
    lerp). Retail has a different crossfade — SkyTimeOfDay step
    blending via LightTickSize gating (_DAT_008427b8 + _DAT_007c7208)
    — which is the deferred Phase 5c "polish" item.

Result:
  - Clear: keyframe fog passthrough — unchanged behaviour.
  - Overcast / Rain / Snow / Storm: now ALSO keyframe passthrough.
    Previously these clobbered the keyframe with the invented
    constants, producing a grey-wall sky that extended no further
    than ~150m. User observation 2026-04-23: "retail sky extends
    all the way into the horizon, we cap at a grey wall." Fixed.
  - EnvironOverride (AdminEnvirons RedFog, BlueFog, etc):
    substitutes the fog COLOR preset, keeps keyframe distances.

WeatherKind enum retained as purely informational (debug overlay,
telemetry). Internal RollKind fallback retained for offline tests
that drive Tick() directly without SetKindFromDayGroupName.
TriggerFlash()/flash decay retained as a test-only hook for the
UBO's lightning-flash channel — production flash stays 0 since
retail drives lightning visuals through particle emitters, not
through a UBO uniform.

Tests updated: `Transition_EasesAcrossTenSeconds` deleted (codified
the Storm=dense-fog invention we just removed) and replaced by
`Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind` which asserts
every WeatherKind returns the keyframe fog directly.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 12:55:19 +02:00
parent e4cf3a9b6b
commit 889b235886
2 changed files with 84 additions and 97 deletions

View file

@ -96,47 +96,42 @@ public readonly record struct AtmosphereSnapshot(
/// </summary> /// </summary>
public sealed class WeatherSystem public sealed class WeatherSystem
{ {
/// <summary>
/// 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 <see cref="WeatherKind"/>s (no such
/// concept in the decompile). The SkyTimeOfDay keyframe interp
/// does all time-based fog/light blending directly.
/// </summary>
public const float TransitionSeconds = 10f; public const float TransitionSeconds = 10f;
// Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms // Flash decay kept so TriggerFlash() is still a usable test hook;
// and decays exponentially with a time constant of ~200ms. // production code (PlayScript-driven lightning, Phase 6) does NOT
private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds // drive the flash uniform — it spawns particle emitters directly.
private const float FlashDecay = 1f / 0.200f; // 1 / τ sec
private const float FlashPeakHoldS = 0.05f; private const float FlashPeakHoldS = 0.05f;
// Retail storm cadence: 830 seconds between strikes. private WeatherKind _kind = WeatherKind.Clear;
private const float StrikeIntervalMinS = 8f;
private const float StrikeIntervalMaxS = 30f;
// Overcast-kind fog feels like ~40150m 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 WeatherKind _previousKind = WeatherKind.Clear;
private float _transitionT; // 0..1 through the cross-fade
private float _flashLevel; private float _flashLevel;
private float _flashAge; // seconds since last strike private float _flashAge;
private float _nextStrikeInS;
private EnvironOverride _override; private EnvironOverride _override;
private int _rolledDayIndex = int.MinValue; // unrolled == "pick one" private int _rolledDayIndex = int.MinValue;
// Phase 3e — when GameWindow (via RefreshSkyForCurrentDay) pushes // Phase 3e — when GameWindow pushes the retail DayGroup name via
// the active retail DayGroup name through SetKindFromDayGroupName, // SetKindFromDayGroupName, the internal RollKind hash is disabled.
// 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 bool _externallyDriven;
private readonly Random _strikeJitter;
public WeatherSystem(Random? rng = null) public WeatherSystem(Random? rng = null)
{ {
_strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u)); // The random-seed ctor argument remains for test API compat,
_nextStrikeInS = 12f; // 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;
} }
/// <summary>Current active weather.</summary> /// <summary>Current active weather.</summary>
@ -232,15 +227,19 @@ public sealed class WeatherSystem
/// </summary> /// </summary>
public void Tick(double nowSeconds, int dayIndex, float dtSeconds) public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
{ {
// Cross-fade progression: transitionT advances toward 1 over // Phase 7 — dropped:
// TransitionSeconds. Capped; no further rollover. // - per-Kind cross-fade (_transitionT drove the now-removed
if (_transitionT < 1f) // FogForKind lerp; retail has no such machinery).
_transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds); // - 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 // Day changed → re-roll (fallback only — disabled when externally driven).
// 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 if (!_externallyDriven
&& dayIndex != _rolledDayIndex && dayIndex != _rolledDayIndex
&& _rolledDayIndex != int.MaxValue) && _rolledDayIndex != int.MaxValue)
@ -250,19 +249,9 @@ public sealed class WeatherSystem
if (newKind != _kind) BeginTransition(newKind); if (newKind != _kind) BeginTransition(newKind);
} }
// Lightning timer only ticks in Storm kind. // Flash decay — 50ms hold then exponential decay (~200ms τ).
if (_kind == WeatherKind.Storm && _override == EnvironOverride.None) // Production never TriggerFlashes; this exists for tests that
{ // exercise the UBO channel.
_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) if (_flashLevel > 0f)
{ {
_flashAge += dtSeconds; _flashAge += dtSeconds;
@ -284,40 +273,45 @@ public sealed class WeatherSystem
} }
/// <summary> /// <summary>
/// Produce the per-frame snapshot consumed by the shader UBO + /// Produce the per-frame atmosphere snapshot from the sky keyframe.
/// particle emitter spawners. Combines the sky keyframe's fog with ///
/// the weather state's fog overlay, then applies the server /// <para>
/// <see cref="EnvironOverride"/> tint if any. /// <b>Retail-faithful since Phase 7 (2026-04-23):</b> fog is the
/// keyframe's fog, passed through directly (color + distances).
/// The only override channel is <see cref="EnvironOverride"/> set
/// by the server's <c>AdminEnvirons</c> 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-<see cref="WeatherKind"/> fog manipulation: retail's
/// decompile (Agent #3, 2026-04-23) contains no such logic. The
/// <see cref="WeatherKind"/> enum is now purely informational — it
/// labels the current sky style for debug overlays but doesn't
/// drive any rendering.
/// </para>
/// </summary> /// </summary>
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf) public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
{ {
// Cross-fade fog distance + color from previous-kind to new-kind. // Fog passthrough from the keyframe (retail semantics).
var prev = FogForKind(_previousKind, kf); Vector3 fogColor = kf.FogColor;
var curr = FogForKind(_kind, kf); float fogStart = kf.FogStart;
float fogEnd = kf.FogEnd;
float t = _transitionT; // AdminEnvirons server override: replace fog COLOR only.
var fogColor = Vector3.Lerp(prev.color, curr.color, t); // Keyframe distances unchanged until we find evidence retail
float fogStart = prev.start + (curr.start - prev.start) * t; // changes those too (Agent #3 notes the in-game crossfade
float fogEnd = prev.end + (curr.end - prev.end) * t; // lerps distances via SkyTimeOfDay keyframe interp, NOT via
// AdminEnvirons directly).
// Server environ override wins.
if (_override != EnvironOverride.None) if (_override != EnvironOverride.None)
{
fogColor = EnvironOverrideColor(_override); fogColor = EnvironOverrideColor(_override);
fogStart = 15f;
fogEnd = 80f; // Dense override fog
}
float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
return new AtmosphereSnapshot( return new AtmosphereSnapshot(
Kind: _kind, Kind: _kind, // informational
Intensity: Math.Clamp(intensity, 0f, 1f), Intensity: 1f, // no per-Kind easing in retail
FogColor: fogColor, FogColor: fogColor,
FogStart: fogStart, FogStart: fogStart,
FogEnd: fogEnd, FogEnd: fogEnd,
FogMode: kf.FogMode, FogMode: kf.FogMode,
LightningFlash: _flashLevel, LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
Override: _override); Override: _override);
} }
@ -329,7 +323,6 @@ public sealed class WeatherSystem
{ {
_previousKind = _kind; _previousKind = _kind;
_kind = newKind; _kind = newKind;
_transitionT = 0f;
} }
/// <summary> /// <summary>
@ -354,23 +347,6 @@ public sealed class WeatherSystem
return WeatherKind.Storm; 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 private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
{ {
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f), EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),

View file

@ -38,19 +38,30 @@ public sealed class WeatherSystemTests
} }
[Fact] [Fact]
public void Transition_EasesAcrossTenSeconds() public void Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind()
{ {
// Force Storm, then Clear, sample snapshot fog distance mid-transition. // Phase 7: retail DOES NOT override fog by WeatherKind — Storm
var sys = new WeatherSystem(); // doesn't produce denser fog, Overcast doesn't shrink distance.
sys.ForceWeather(WeatherKind.Storm); // Every Kind renders the keyframe's fog directly. This test
sys.Tick(0, 1, 100f); // finalize // replaces the old "Transition_EasesAcrossTenSeconds" which
// codified the invented per-Kind fog behaviour.
var kf = SkyStateProvider.Default().Interpolate(0.5f); 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). foreach (var kind in new[] {
Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}"); 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] [Fact]