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