acdream/src/AcDream.Core/World/WeatherState.cs
Erik 22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00

382 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.16.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 830 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;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11): retail <c>PlayerOption
/// DisableDistanceFog</c> — <c>CPlayerModule::OnChanged @0x0059A8E0</c>
/// case <c>0x30</c> writes <c>LScape::m_fFogEnabled = !value</c>. Polled
/// (not pushed) so the App composition layer only needs to bind this
/// ONCE per session to a live <c>RuntimeCharacterOptionsState.GetOptionBit</c>
/// reader — every later toggle (the panel row, a bot, a slash command)
/// is reflected with no separate notify/resync wiring. Null (the
/// default) means the option was never bound — fog stays enabled,
/// matching retail's <c>m_fFogEnabled</c> ctor default.
/// </summary>
public Func<bool>? DisableDistanceFogSource { get; set; }
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;
FogMode fogMode = kf.FogMode;
// 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);
// OP4 (2026-08-11): PlayerOption DisableDistanceFog ->
// LScape::m_fFogEnabled = !value (see DisableDistanceFogSource's
// doc comment). Forces FogMode.Off regardless of the keyframe's
// own authored mode; distances are left alone since the shader
// never reads them once fog is off.
if (DisableDistanceFogSource?.Invoke() == true)
fogMode = FogMode.Off;
return new AtmosphereSnapshot(
Kind: _kind, // informational
Intensity: 1f, // no per-Kind easing in retail
FogColor: fogColor,
FogStart: fogStart,
FogEnd: fogEnd,
FogMode: 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),
};
}