Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex <codex@openai.com>
360 lines
15 KiB
C#
360 lines
15 KiB
C#
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.World;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
///
|
||
/// <para>
|
||
/// The rendering side reads <see cref="Kind"/> to decide whether to
|
||
/// spawn rain/snow particles and which cloud mesh override to select.
|
||
/// The <see cref="Intensity"/> field lets the fog / particle rate /
|
||
/// cloud-darkness terms ease in and out smoothly rather than popping.
|
||
/// </para>
|
||
/// </summary>
|
||
public enum WeatherKind
|
||
{
|
||
Clear = 0,
|
||
Overcast = 1,
|
||
Rain = 2,
|
||
Snow = 3,
|
||
Storm = 4,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Server-forced fog override (retail <c>EnvironChangeType</c>). When
|
||
/// the server sends <c>AdminEnvirons</c> (0xEA60) with one of the
|
||
/// non-<see cref="None"/> values, the client overrides its locally-computed
|
||
/// fog color and density with the tint shown below. See r12 §5.2 and
|
||
/// <c>references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs</c>.
|
||
/// </summary>
|
||
public enum EnvironOverride
|
||
{
|
||
None = 0x00, // clear override, revert to dat-driven fog
|
||
RedFog = 0x01,
|
||
BlueFog = 0x02,
|
||
WhiteFog = 0x03,
|
||
GreenFog = 0x04,
|
||
BlackFog = 0x05,
|
||
BlackFog2 = 0x06,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Full per-frame atmosphere state consumed by the shader + particle
|
||
/// systems. Built by <see cref="WeatherSystem"/> from
|
||
/// <list type="bullet">
|
||
/// <item><description>the interpolated <see cref="SkyKeyframe"/>,</description></item>
|
||
/// <item><description>the current <see cref="WeatherKind"/>,</description></item>
|
||
/// <item><description>a possibly-active <see cref="EnvironOverride"/>,</description></item>
|
||
/// <item><description>a transient lightning-flash bump.</description></item>
|
||
/// </list>
|
||
/// </summary>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
///
|
||
/// <para>
|
||
/// Algorithm (r12 §6.1–6.2):
|
||
/// <list type="number">
|
||
/// <item><description>
|
||
/// Derive a deterministic <c>Random(dayIndex)</c> per in-game day.
|
||
/// Roll a weighted pick from a table matching retail's rough
|
||
/// 70/15/10/5 distribution (Clear dominates).
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// When the kind changes, store a <c>transitionStart</c> timestamp
|
||
/// and tween <see cref="AtmosphereSnapshot.Intensity"/> from 0 → 1
|
||
/// over <see cref="TransitionSeconds"/>.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// Storm kind only: every 8–30 seconds fire a lightning flash; the
|
||
/// shader reads <see cref="AtmosphereSnapshot.LightningFlash"/> as
|
||
/// an additive scene bump that decays with a 200 ms time constant.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// Any server <see cref="EnvironOverride"/> beats the local picks —
|
||
/// stick the override fog color and density in the snapshot until
|
||
/// the server sends <see cref="EnvironOverride.None"/>.
|
||
/// </description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </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 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;
|
||
}
|
||
|
||
/// <summary>Current active weather.</summary>
|
||
public WeatherKind Kind => _kind;
|
||
|
||
/// <summary>Last-known server fog override (sticky between sync packets).</summary>
|
||
public EnvironOverride Override
|
||
{
|
||
get => _override;
|
||
set => _override = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Debug / test hook — force a specific weather kind, ignoring the
|
||
/// per-day roll. Passing <see cref="WeatherKind.Clear"/> returns to
|
||
/// normal behavior starting on the next day-roll.
|
||
/// </summary>
|
||
public void ForceWeather(WeatherKind kind)
|
||
{
|
||
BeginTransition(kind);
|
||
_rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drive the weather kind from the active retail DayGroup name
|
||
/// (see <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</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();
|
||
// 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
|
||
}
|
||
|
||
/// <summary>
|
||
/// Advance the state machine. Call once per frame from the render
|
||
/// loop. <paramref name="dayIndex"/> is the in-game day (derived
|
||
/// from <see cref="DerethDateTime"/>); when it changes we re-roll
|
||
/// the weather kind.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Trigger a lightning flash manually (server-forced or test hook).
|
||
/// </summary>
|
||
public void TriggerFlash()
|
||
{
|
||
_flashLevel = 1f;
|
||
_flashAge = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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)
|
||
{
|
||
// 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Deterministic per-day weighted roll. Seeded with <paramref name="dayIndex"/>
|
||
/// alone so every client running the same day sees the same weather —
|
||
/// retail's mechanism for "synchronized weather without any packets"
|
||
/// (r12 §6.1).
|
||
/// </summary>
|
||
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),
|
||
};
|
||
}
|