acdream/src/AcDream.Core/World/SkyState.cs
Erik 57c11358b6 fix(sky): A7 — correct sun-vector magnitude (ambient + sun were ~32% too bright)
Outdoor lighting was ~32% too bright (washed-out, weak shading). Live cdb on
retail (SmartBox::SetWorldAmbientLight + SkyDesc::GetLighting + LScape::sunlight,
binary matches refs/acclient.pdb) pinned it: at the SAME game time + DayGroup,
acdream's ambient COLOR matched retail exactly (the purple is correct, authored
per-time-of-day in the sky dat) but the LEVEL was 0.607 vs retail's 0.459.

level = AmbBright + 0.2·|sunVec|, both AmbBright=0.40, so acdream's |sunVec|≈1.06
vs retail's ≈0.30. Retail's LScape::sunlight read live = (0.2238, ~0, 0.00352),
magnitude 0.224 = DirBright, y≈0.

RetailSunVector had `y = cos(P)` (≈1) — the raw PRE-transform value SkyDesc::
GetLighting writes to arg5 (0x00500ac9), before LScape::set_sky_position's
world transform. acdream ported the un-transformed vector, so the y=cos(P)≈1
term inflated |sunVec| to ~1.06. That magnitude feeds BOTH the ambient boost
(SkyKeyframe.AmbientColor) AND the sun colour (SkyKeyframe.SunColor =
DirColor×|sunVec|), over-brightening the whole scene (terrain, objects, sky)
~30% and also pointing the sun the wrong way.

Fix: RetailSunVector = DirBright × (cos(P)·sin(H), cos(P)·cos(H), sin(P)) — the
world-space spherical form LScape::sunlight actually holds; |sunVec| == DirBright
for all H/P. After: acdream ambient (0.353,0.176,0.449) vs retail (0.360,0.180,
0.459) — within ~2%, user-confirmed "better outside". Sun direction also corrected
(was pointing ~North from the bad y term).

Tests updated to the cdb-verified values (the prior tests pinned the inflated
magnitude). 18/18 sky tests green. reference-retail-ambient-values memory updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:08:52 +02:00

467 lines
20 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.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
/// supported by the dat schema but never appear in shipped data. See r12
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
/// </summary>
public enum FogMode
{
Off = 0,
Linear = 1,
Exp = 2,
Exp2 = 3,
}
/// <summary>
/// One sky keyframe — the full lighting + fog state for a specific
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
/// linearly (with angular-shortest-arc wrap on sun direction) to produce
/// the current sky state.
///
/// <para>
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
/// references to sky objects (sun mesh, moon mesh, cloud layer) which
/// belong to the renderer. This record exposes the shader-relevant
/// subset — sun direction, sun color, ambient color, linear fog. See
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
/// and r12 §4 + §5.
/// </para>
///
/// <para>
/// Colors are stored RAW (NOT pre-multiplied by brightness) in
/// <see cref="DirColor"/> / <see cref="AmbColor"/> with the brightness
/// scalars in <see cref="DirBright"/> / <see cref="AmbBright"/>. Retail's
/// <c>SkyDesc::GetLighting</c> at <c>0x00500ac9</c> (decomp lines
/// 261317-261331) lerps each channel separately and lerps brightness
/// separately, then multiplies post-lerp. Lerping the pre-multiplied
/// product gives mathematically different results when both color and
/// brightness change between adjacent keyframes — the cause of subtle
/// brightness discrepancies vs retail observed in dual-client
/// comparisons (Issue #3 visual sub-bug, 2026-04-27).
/// </para>
/// <para>
/// The computed properties <see cref="SunColor"/> and
/// <see cref="AmbientColor"/> return the post-multiplied product, so
/// downstream shader uniform plumbing (sky.vert / mesh.vert /
/// SceneLightingUbo) is unchanged.
/// </para>
/// </summary>
public readonly record struct SkyKeyframe(
float Begin, // [0, 1] day-fraction this keyframe kicks in
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
Vector3 DirColor, // RGB linear, RAW (NOT × DirBright)
float DirBright, // sun brightness multiplier
Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright)
float AmbBright, // ambient brightness multiplier
Vector3 FogColor,
float FogDensity, // retained for tests; derive from FogStart/End
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
FogMode FogMode = FogMode.Linear)
{
/// <summary>
/// Final directional sun color the shader feeds into N·L lighting.
/// Retail-faithful magnitude formula:
/// <code>SunColor = DirColor × |sunVec|</code>
/// where <c>sunVec</c> is retail's heading+pitch+brightness vector
/// (see <see cref="SkyStateProvider.RetailSunVector"/>).
///
/// <para>
/// <c>|sunVec|</c> is retail's <c>D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²)</c>
/// scaling (<c>PrimD3DRender::UpdateLightsInternal</c> 0x0059b57c, decomp
/// 424118-424119) of the WORLD-space sun vector (<c>LScape::sunlight</c>).
/// Because <see cref="SkyStateProvider.RetailSunVector"/> is now the
/// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified —
/// see that method), <c>|sunVec| == DirBright</c>, so this is effectively
/// <c>SunColor = DirColor × DirBright</c>. (A prior bug used the un-transformed
/// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~45× too bright at dawn/dusk;
/// [[reference-retail-ambient-values]].)
/// </para>
/// </summary>
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
/// <summary>
/// Final ambient color the shader feeds into the per-vertex tint.
/// Retail-faithful magnitude formula:
/// <code>AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)</code>
/// matching <c>SmartBox::SetWorldAmbientLight</c> as called at
/// <c>0x0050560b</c> (decomp line 267117):
/// <code>SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color)</code>
/// Retail boosts the ambient brightness by 20% of the sun-vector
/// magnitude — i.e. ambient feels warmer when the sun is up, cooler
/// at night. acdream previously used <c>AmbBright</c> alone, which
/// is roughly 44% too dim mid-day ⇒ contributed to the blue-white
/// bias because the warm fill was missing.
/// </summary>
public Vector3 AmbientColor =>
AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length());
}
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
/// the blended lighting state between the surrounding keyframes.
///
/// <para>
/// Math (r12 §4):
/// <list type="number">
/// <item><description>
/// Pick the two keyframes bracketing <c>t</c>: <c>k1</c> = last
/// keyframe with <c>Begin &lt;= t</c>, <c>k2</c> = next keyframe
/// (wraps: if <c>k1</c> is last, <c>k2</c> is first).
/// </description></item>
/// <item><description>
/// Local blend <c>u = (t - k1.Begin) / (k2.Begin - k1.Begin)</c>
/// with wrap handling.
/// </description></item>
/// <item><description>
/// Lerp every vector component; use shortest-arc lerp for the sun
/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public sealed class SkyStateProvider
{
private readonly List<SkyKeyframe> _keyframes;
public SkyStateProvider(IReadOnlyList<SkyKeyframe> keyframes)
{
if (keyframes is null || keyframes.Count == 0)
throw new ArgumentException("At least one keyframe required", nameof(keyframes));
// Sort by Begin so the walk is deterministic regardless of input order.
var sorted = new List<SkyKeyframe>(keyframes);
sorted.Sort((a, b) => a.Begin.CompareTo(b.Begin));
_keyframes = sorted;
}
public int KeyframeCount => _keyframes.Count;
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
/// <summary>
/// Default keyframe set based on retail observations — sunrise at 6am,
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't
/// available yet or the player is in a region whose Region dat
/// doesn't override it.
///
/// <para>
/// Fog values approximate retail clear-weather defaults: ~80m..~350m
/// linear fog with color matching the horizon band so mountains at
/// distance fade into the sky instead of popping at the clip plane.
/// See r12 §5.1.
/// </para>
/// </summary>
public static SkyStateProvider Default()
{
// Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk.
return new SkyStateProvider(new[]
{
// Default factory: brightness scalars are 1.0 here — the colors
// ARE the final intended values. Live Dereth keyframes loaded
// from the dat have separate non-1.0 DirBright/AmbBright values
// and the renderer multiplies them post-lerp.
new SkyKeyframe(
Begin: 0.0f,
SunHeadingDeg: 0f, // below horizon (north)
SunPitchDeg: -30f,
DirColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
DirBright: 1.0f,
AmbColor: new Vector3(0.05f, 0.05f, 0.12f),
AmbBright: 1.0f,
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
FogDensity: 0.004f,
FogStart: 30f,
FogEnd: 180f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
SunPitchDeg: 0f,
DirColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
DirBright: 1.0f,
AmbColor: new Vector3(0.4f, 0.35f, 0.3f),
AmbBright: 1.0f,
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
SunPitchDeg: 70f,
DirColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
DirBright: 1.0f,
AmbColor: new Vector3(0.5f, 0.5f, 0.55f),
AmbBright: 1.0f,
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
FogDensity: 0.0008f,
FogStart: 120f,
FogEnd: 500f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
SunPitchDeg: 0f,
DirColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
DirBright: 1.0f,
AmbColor: new Vector3(0.35f, 0.25f, 0.25f),
AmbBright: 1.0f,
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
});
}
/// <summary>
/// Current interpolated sky state at day fraction <paramref name="t"/>.
/// Wraps correctly across the day boundary (1.0 → 0.0).
/// </summary>
public SkyKeyframe Interpolate(float t)
{
t = (float)(t - Math.Floor(t)); // wrap to [0, 1)
// Find k1: last keyframe with Begin <= t.
int k1Index = _keyframes.Count - 1;
for (int i = 0; i < _keyframes.Count; i++)
{
if (_keyframes[i].Begin <= t)
k1Index = i;
else
break;
}
int k2Index = (k1Index + 1) % _keyframes.Count;
var k1 = _keyframes[k1Index];
var k2 = _keyframes[k2Index];
// Compute blend weight, handling wrap (k1 is last, k2 is first).
float k1Begin = k1.Begin;
float k2Begin = k2.Begin;
if (k2Begin <= k1Begin) k2Begin += 1.0f; // unroll wrap
float tWrapped = t;
if (tWrapped < k1Begin) tWrapped += 1.0f;
float span = Math.Max(1e-6f, k2Begin - k1Begin);
float u = (tWrapped - k1Begin) / span;
u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc.
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
// Retail-faithful interpolation: lerp DirColor / DirBright /
// AmbColor / AmbBright as SEPARATE CHANNELS, not as the
// pre-multiplied product. Mirrors SkyDesc::GetLighting at
// 0x00500ac9 (decomp lines 261317-261331). The post-multiplied
// SunColor / AmbientColor are computed properties on the result.
// Fog mode doesn't interpolate — pick k1's mode (retail uses
// Linear everywhere).
return new SkyKeyframe(
Begin: t,
SunHeadingDeg: heading,
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
DirColor: Vector3.Lerp(k1.DirColor, k2.DirColor, u),
DirBright: Lerp(k1.DirBright, k2.DirBright, u),
AmbColor: Vector3.Lerp(k1.AmbColor, k2.AmbColor, u),
AmbBright: Lerp(k1.AmbBright, k2.AmbBright, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
FogMode: k1.FogMode);
}
private static float Lerp(float a, float b, float u) => a + (b - a) * u;
/// <summary>
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
/// the lerp walks 20° forward through 0° rather than 340° backward.
/// </summary>
public static float ShortestAngleLerp(float aDeg, float bDeg, float u)
{
float delta = bDeg - aDeg;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
return aDeg + delta * u;
}
/// <summary>
/// Retail's world-space sun vector (NOT normalized): the standard
/// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by
/// <c>DirBright</c>:
/// <code>
/// sunVec.x = DirBright × cos(P) × sin(H)
/// sunVec.y = DirBright × cos(P) × cos(H)
/// sunVec.z = DirBright × sin(P)
/// </code>
/// so <c>|sunVec| == DirBright</c> exactly (cos²P·(sin²H+cos²H)+sin²P = 1).
///
/// <para>
/// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]):
/// retail's <c>LScape::sunlight</c> read at a dawn keyframe (H=90°, P=0.9°,
/// DirBright≈0.224) = <c>(0.2238, ~0, 0.00352)</c> — y≈0, magnitude 0.224 =
/// DirBright. That fed <c>level = 0.2·|sunlight| + ambient_level = 0.2·0.224 +
/// 0.40 = 0.445</c>, matching the captured <c>SetWorldAmbientLight</c> level.
/// </para>
/// <para>
/// PRIOR BUG: an earlier version returned <c>y = cos(P)</c> (≈1) — the raw
/// PRE-transform value the decomp's <c>SkyDesc::GetLighting</c> writes to its
/// <c>arg5</c> (0x00500ac9, before <c>LScape::set_sky_position</c>'s world
/// transform). Porting that un-transformed vector inflated <c>|sunVec|</c> to
/// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost
/// (<see cref="SkyKeyframe.AmbientColor"/>) AND the sun colour
/// (<see cref="SkyKeyframe.SunColor"/>) by ~30% vs retail. The world-space
/// form above is what <c>LScape::sunlight</c> actually holds at runtime.
/// </para>
/// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright)
/// feeds the sun-colour intensity and the ambient brightness boost.
/// </summary>
public static Vector3 RetailSunVector(SkyKeyframe kf)
{
float h = kf.SunHeadingDeg * (MathF.PI / 180f);
float p = kf.SunPitchDeg * (MathF.PI / 180f);
float cosP = MathF.Cos(p);
float sinP = MathF.Sin(p);
float B = kf.DirBright;
return new Vector3(
B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H)
B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H)
B * sinP); // z = DirBright × sin(P)
}
/// <summary>
/// World-space sun direction unit vector pointing FROM the surface
/// TOWARDS the sun, derived from <see cref="RetailSunVector"/> and
/// normalized. The shader sunDir uniform should use this directly
/// (or -this if the lighting math wants the L-vector pointing AT the
/// surface). The previous implementation used standard spherical
/// coordinates (sin(H)cos(P), cos(H)cos(P), sin(P)) which didn't match
/// retail's deliberate Y-decoupled-from-heading convention. Switching
/// to the retail vector subtly tilts the lighting on objects but
/// matches retail's screenshots when both clients view the same scene.
/// </summary>
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
{
var v = RetailSunVector(kf);
float len = v.Length();
return len > 1e-6f ? v / len : Vector3.UnitZ;
}
}
/// <summary>
/// Service that turns server-delivered tick counts into live sky state.
/// Owns the "current time" clock (seeded from server sync, advanced by
/// real-time elapsed between syncs).
///
/// <para>
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) that
/// forces a specific day fraction regardless of server sync — used for
/// screenshots and visual debugging. The override is transient and gets
/// cleared on the next TimeSync packet.
/// </para>
/// </summary>
public sealed class WorldTimeService
{
private SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
private float? _debugDayFractionOverride;
/// <summary>
/// Rate at which in-game time advances relative to real time. Retail
/// default is 1.0 (one wall-clock second = one in-game tick). Server
/// config can override via <c>SkyDesc.TickSize</c>; see r12 §1.2.
/// </summary>
public double TickSize { get; set; } = 1.0;
public WorldTimeService(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Hot-swap the keyframe source — typically called once at world-load
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
/// </summary>
public void SetProvider(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Set the authoritative tick count from a server TimeSync packet.
/// Clears any debug override.
/// </summary>
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = System.DateTime.UtcNow;
_debugDayFractionOverride = null;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
{
var df = DerethDateTime.DayFraction(serverTicks);
var cal = DerethDateTime.ToCalendar(serverTicks);
System.Console.WriteLine(
$"[sky-dump] SyncFromServer: ticks={serverTicks:F1} dayFraction={df:F4} " +
$"calendar=PY{cal.Year} {cal.Month} {cal.Day} {cal.Hour}");
}
}
/// <summary>
/// Debug-only: force a specific day fraction in [0, 1). Overrides
/// server-synced time until cleared by <see cref="SyncFromServer"/>
/// or <see cref="ClearDebugTime"/>.
/// </summary>
public void SetDebugTime(float dayFraction)
{
_debugDayFractionOverride = dayFraction;
}
public void ClearDebugTime() => _debugDayFractionOverride = null;
/// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
/// </summary>
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
return _lastSyncedTicks + elapsed * TickSize;
}
}
/// <summary>Current day fraction in [0, 1).</summary>
public double DayFraction
{
get
{
if (_debugDayFractionOverride.HasValue)
return _debugDayFractionOverride.Value;
return DerethDateTime.DayFraction(NowTicks);
}
}
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
/// <summary>Convenience: current sun direction from derived sky state.</summary>
public Vector3 CurrentSunDirection =>
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);
public DerethDateTime.Calendar CurrentCalendar =>
DerethDateTime.ToCalendar(NowTicks);
public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks);
}