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>
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;
// 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: 830 seconds between strikes.
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 _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;
}
/// <summary>Current active weather.</summary>
@ -232,15 +227,19 @@ public sealed class WeatherSystem
/// </summary>
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
}
/// <summary>
/// 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
/// <see cref="EnvironOverride"/> tint if any.
/// Produce the per-frame atmosphere snapshot from the sky keyframe.
///
/// <para>
/// <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>
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;
}
/// <summary>
@ -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),

View file

@ -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]