using System;
using System.Numerics;
namespace AcDream.Core.World;
///
/// Client-local atmospheric regime. Retail AC has no server weather
/// opcode (r12 §6) — the client picks a state per in-game day via a
/// deterministic seeded RNG so all players on the same server see the
/// same weather without any packets. Transitions take ~10 seconds.
///
///
/// The rendering side reads to decide whether to
/// spawn rain/snow particles and which cloud mesh override to select.
/// The field lets the fog / particle rate /
/// cloud-darkness terms ease in and out smoothly rather than popping.
///
///
public enum WeatherKind
{
Clear = 0,
Overcast = 1,
Rain = 2,
Snow = 3,
Storm = 4,
}
///
/// Server-forced fog override (retail EnvironChangeType). When
/// the server sends AdminEnvirons (0xEA60) with one of the
/// non- values, the client overrides its locally-computed
/// fog color and density with the tint shown below. See r12 §5.2 and
/// references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs.
///
public enum EnvironOverride
{
None = 0x00, // clear override, revert to dat-driven fog
RedFog = 0x01,
BlueFog = 0x02,
WhiteFog = 0x03,
GreenFog = 0x04,
BlackFog = 0x05,
BlackFog2 = 0x06,
}
///
/// Full per-frame atmosphere state consumed by the shader + particle
/// systems. Built by from
///
/// - the interpolated ,
/// - the current ,
/// - a possibly-active ,
/// - a transient lightning-flash bump.
///
///
public readonly record struct AtmosphereSnapshot(
WeatherKind Kind,
float Intensity, // 0..1, eases on state transitions
Vector3 FogColor, // final fog color (may be overridden)
float FogStart,
float FogEnd,
FogMode FogMode,
float LightningFlash, // 0..1, decays from strike moment
EnvironOverride Override);
///
/// Weather state machine — deterministic per-day RNG picks the weather
/// kind; a 10-second ease blends fog + particle density between old
/// and new states. Also owns the lightning-flash timer for storms.
///
///
/// Algorithm (r12 §6.1–6.2):
///
/// -
/// Derive a deterministic Random(dayIndex) per in-game day.
/// Roll a weighted pick from a table matching retail's rough
/// 70/15/10/5 distribution (Clear dominates).
///
/// -
/// When the kind changes, store a transitionStart timestamp
/// and tween from 0 → 1
/// over .
///
/// -
/// Storm kind only: every 8–30 seconds fire a lightning flash; the
/// shader reads as
/// an additive scene bump that decays with a 200 ms time constant.
///
/// -
/// Any server beats the local picks —
/// stick the override fog color and density in the snapshot until
/// the server sends .
///
///
///
///
public sealed class WeatherSystem
{
///
/// 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 s (no such
/// concept in the decompile). The SkyTimeOfDay keyframe interp
/// does all time-based fog/light blending directly.
///
public const float TransitionSeconds = 10f;
// 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;
private WeatherKind _kind = WeatherKind.Clear;
private WeatherKind _previousKind = WeatherKind.Clear;
private float _flashLevel;
private float _flashAge;
private EnvironOverride _override;
private int _rolledDayIndex = int.MinValue;
// Phase 3e — when GameWindow pushes the retail DayGroup name via
// SetKindFromDayGroupName, the internal RollKind hash is disabled.
private bool _externallyDriven;
public WeatherSystem(Random? rng = null)
{
// 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;
}
/// Current active weather.
public WeatherKind Kind => _kind;
/// Last-known server fog override (sticky between sync packets).
public EnvironOverride Override
{
get => _override;
set => _override = value;
}
///
/// Debug / test hook — force a specific weather kind, ignoring the
/// per-day roll. Passing returns to
/// normal behavior starting on the next day-roll.
///
public void ForceWeather(WeatherKind kind)
{
BeginTransition(kind);
_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();
// Retail DOES NOT spawn rain/snow/storm particles based on the
// DayGroup's NAME. Parallel decompile research 2026-04-23
// (docs/research/2026-04-23-sky-pes-wiring.md +
// docs/research/2026-04-23-physicsscript.md) verified:
//
// 1. FUN_00508010 (the sky render loop) never reads
// SkyObject.DefaultPesObjectId — the field is dead at
// render time.
// 2. The PhysicsScript runtime (FUN_0051bed0 → FUN_0051bfb0)
// has no callers from the sky-render tree.
// 3. r12 deepdive claim that retail spawns rain from a sky
// SkyObject's PES was not corroborated by the decompile.
//
// Weather particle emission in retail therefore belongs to a
// SEPARATE camera-attached subsystem, not yet located. Until we
// find and port that subsystem, we must NOT invent our own
// "Rainy DayGroup name → spawn rain particles" path — it produced
// the user-observed regression 2026-04-23 (acdream rained on a
// DayGroup that retail rendered without any rain particles).
//
// Therefore ALL weathery names map to Overcast — they get the
// correct keyframe-driven fog/cloud tone, without the particle
// emitter. Clear names stay Clear. No Rain / Snow / Storm is
// ever returned from name matching. Tests kept for Storm/Rain
// constants since ForceWeather still supports them for debug.
if (lc.Contains("storm")
|| lc.Contains("snow")
|| lc.Contains("rain")
|| 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
/// from ); when it changes we re-roll
/// the weather kind.
///
public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
{
// 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 (fallback only — disabled when externally driven).
if (!_externallyDriven
&& dayIndex != _rolledDayIndex
&& _rolledDayIndex != int.MaxValue)
{
_rolledDayIndex = dayIndex;
var newKind = RollKind(dayIndex);
if (newKind != _kind) BeginTransition(newKind);
}
// 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;
if (_flashAge < FlashPeakHoldS)
_flashLevel = 1f;
else
_flashLevel = MathF.Exp(-(_flashAge - FlashPeakHoldS) * FlashDecay);
if (_flashLevel < 1e-3f) _flashLevel = 0f;
}
}
///
/// Trigger a lightning flash manually (server-forced or test hook).
///
public void TriggerFlash()
{
_flashLevel = 1f;
_flashAge = 0f;
}
///
/// Produce the per-frame atmosphere snapshot from the sky keyframe.
///
///
/// Retail-faithful since Phase 7 (2026-04-23): fog is the
/// keyframe's fog, passed through directly (color + distances).
/// The only override channel is set
/// by the server's AdminEnvirons 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- fog manipulation: retail's
/// decompile (Agent #3, 2026-04-23) contains no such logic. The
/// enum is now purely informational — it
/// labels the current sky style for debug overlays but doesn't
/// drive any rendering.
///
///
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
{
// Fog passthrough from the keyframe (retail semantics).
Vector3 fogColor = kf.FogColor;
float fogStart = kf.FogStart;
float fogEnd = kf.FogEnd;
// 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);
return new AtmosphereSnapshot(
Kind: _kind, // informational
Intensity: 1f, // no per-Kind easing in retail
FogColor: fogColor,
FogStart: fogStart,
FogEnd: fogEnd,
FogMode: kf.FogMode,
LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
Override: _override);
}
// ----------------------------------------------------------------
// Internal machinery
// ----------------------------------------------------------------
private void BeginTransition(WeatherKind newKind)
{
_previousKind = _kind;
_kind = newKind;
}
///
/// Deterministic per-day weighted roll. Seeded with
/// alone so every client running the same day sees the same weather —
/// retail's mechanism for "synchronized weather without any packets"
/// (r12 §6.1).
///
private static WeatherKind RollKind(int dayIndex)
{
// Mix the day index so consecutive days aren't adjacent in PRNG
// state space (avoids tiny-seed correlation issues).
int seed = unchecked((int)((uint)dayIndex * 0x9E3779B1u));
var rng = new Random(seed);
double r = rng.NextDouble();
// Retail weights (approximate): 60% clear, 20% overcast, 12% rain,
// 5% snow, 3% storm. Tuned for "most days are fine, some are bad."
if (r < 0.60) return WeatherKind.Clear;
if (r < 0.80) return WeatherKind.Overcast;
if (r < 0.92) return WeatherKind.Rain;
if (r < 0.97) return WeatherKind.Snow;
return WeatherKind.Storm;
}
private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
{
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),
EnvironOverride.BlueFog => new Vector3(0.08f, 0.15f, 0.60f),
EnvironOverride.WhiteFog => new Vector3(0.90f, 0.90f, 0.92f),
EnvironOverride.GreenFog => new Vector3(0.08f, 0.55f, 0.12f),
EnvironOverride.BlackFog => new Vector3(0.02f, 0.02f, 0.02f),
EnvironOverride.BlackFog2 => new Vector3(0.04f, 0.01f, 0.01f),
_ => new Vector3(1f, 1f, 1f),
};
}