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), }; }