Merge branch 'feature/sky-fixes' — sky/weather rendering retail-faithful pass

Six commits on the branch, three retail-decomp investigations
(in-house + two external code-review agents) converging on the
same root causes:

  97fc1b5 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
  05a8a72 fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
  034a684 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
  375065b fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
  646ccca feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
  0c82d2c docs(issues): #28 root-caused (PES particles), #29 filed

Net effect:

  * Sun + ambient colors now use retail's |sunVec| magnitude formula
    from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes
    blue-white sky tint at most keyframes.
  * Surface.Translucency is used DIRECTLY as opacity (not 1-x) per
    D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright
    cloud + correct rain alpha.
  * Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon
    haze visible without flat-fogging the dome at storm keyframes.
  * Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp
    425295 — sun stays bright at horizon dusk/dawn.
  * Pre/post-scene partition is bit 0x01 (post-scene placement) instead
    of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects
    at decomp 269036. Fixes double-rendered foreground rain.
  * Translucent flag forces alpha-blend over Additive when ClipMap is
    set, matching retail's blend resolution at decomp 425246-425260.
    Cloud surface 0x08000023 now classified correctly.
  * Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten
    instead of being silently dropped by EnsureMeshUploaded.

Tests: 1227 pass.

User-visible improvements: foreground rain matches retail's
volumetric look, sky tint shifted from blue-white toward retail's
warm-gray, additive sun stays bright through horizon haze.

Outstanding:
  * Issue #28 — PES particle rendering ("aurora light play"). Now
    root-caused with implementation outline; defer to its own Phase.
  * Issue #29 — residual cloud-density gap; likely rolls into #28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/AcDream.App/Rendering/GameWindow.cs
This commit is contained in:
Erik 2026-04-27 23:30:50 +02:00
commit f7c9e88b6a
18 changed files with 1439 additions and 290 deletions

View file

@ -75,26 +75,56 @@ public sealed class DerethDateTimeTests
}
[Fact]
public void ToCalendar_PY0Day1_Snowreap()
public void ToCalendar_PY10Day1_Morningthaw()
{
// Tick 0 maps to PY 10 (= relative year 0 + ZeroYear=10),
// Morningthaw 1 — matches retail's calendar epoch
// (ACE DerethDateTime.cs: dayZeroTicks = 0; // Morningthaw 1, 10 P.Y.).
var cal = DerethDateTime.ToCalendar(0);
Assert.Equal(0, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month);
Assert.Equal(DerethDateTime.ZeroYear, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month);
Assert.Equal(1, cal.Day);
}
[Fact]
public void ToCalendar_AdvancesCorrectly()
{
// One year from start → PY 1, Snowreap 1.
// One year from start → PY (10 + 1) = 11, Morningthaw 1.
var cal = DerethDateTime.ToCalendar(DerethDateTime.YearTicks);
Assert.Equal(1, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Snowreap, cal.Month);
Assert.Equal(DerethDateTime.ZeroYear + 1, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Morningthaw, cal.Month);
Assert.Equal(1, cal.Day);
// One month into year 1.
// One month into year 11 → Solclaim (next month after Morningthaw).
var cal2 = DerethDateTime.ToCalendar(DerethDateTime.YearTicks + DerethDateTime.MonthTicks);
Assert.Equal(1, cal2.Year);
Assert.Equal(DerethDateTime.MonthName.ColdMeet, cal2.Month);
Assert.Equal(DerethDateTime.ZeroYear + 1, cal2.Year);
Assert.Equal(DerethDateTime.MonthName.Solclaim, cal2.Month);
}
[Fact]
public void ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat()
{
// Regression guard for the 2026-04-27 dual-client comparison.
// Retail @timestamp output format is
// "Date: <Month> <Day>, <Year> P.Y."
// Pick a tick at the exact start of Seedsow 24 in relative year 106:
// shifted = 106 * YearTicks + 2 * MonthTicks + 23 * DayTicks
// Derived: 290,779,200 + 457,200 + 175,260 = 291,411,660. Subtract
// OriginOffsetTicks (3600 in Dereth dat) to get the input tick:
// 291,411,660 - 3600 = 291,408,060
// Expected output: PY 116 (= ZeroYear 10 + relative 106), Seedsow,
// day 24 1-indexed.
DerethDateTime.SetOriginOffsetFromDat(3600.0);
try
{
var cal = DerethDateTime.ToCalendar(291_408_060.0);
Assert.Equal(DerethDateTime.ZeroYear + 106, cal.Year);
Assert.Equal(DerethDateTime.MonthName.Seedsow, cal.Month);
Assert.Equal(24, cal.Day);
}
finally
{
DerethDateTime.SetOriginOffsetFromDat(DerethDateTime.DayFractionOriginOffsetTicks);
}
}
}

View file

@ -73,15 +73,26 @@ public sealed class SkyDescLoaderTests
}
[Fact]
public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness()
public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude()
{
// The loader stores DirColor and DirBright RAW. The SunColor property
// composes them via |sunVec| per retail's UpdateLightsInternal at
// 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²)
// where the sun vector is built from heading/pitch/brightness with
// Y unscaled by brightness (decomp 261352).
//
// For this region: H=180°, P=70°, B=1.5
// sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70))
// = (0, 0.342, 1.410)
// |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509
// DirColor.X = 200/255 = 0.7843
// SunColor.X = 0.7843 × 1.4509 = 1.138
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
// R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176
Assert.InRange(kf.SunColor.X, 1.17f, 1.19f);
Assert.InRange(kf.SunColor.X, 1.13f, 1.15f);
}
[Fact]

View file

@ -25,17 +25,105 @@ public sealed class SkyStateTests
}
[Fact]
public void Interpolate_BetweenKeyframes_LerpsColors()
public void Interpolate_BetweenKeyframes_LerpsRawInputs()
{
var sky = SkyStateProvider.Default();
var dawn = sky.Interpolate(0.25f);
var noon = sky.Interpolate(0.5f);
var midPt = sky.Interpolate(0.375f);
// Midpoint should fall between dawn & noon for sun color Y (green channel).
float low = System.Math.Min(dawn.SunColor.Y, noon.SunColor.Y);
float high = System.Math.Max(dawn.SunColor.Y, noon.SunColor.Y);
Assert.InRange(midPt.SunColor.Y, low, high);
// The RAW per-channel inputs (DirColor, AmbColor, brightness scalars)
// lerp linearly between adjacent keyframes — that's the retail-faithful
// separate-channel interpolation. The composite SunColor / AmbientColor
// properties intentionally do NOT lerp linearly (their magnitude
// depends nonlinearly on heading/pitch/brightness via the retail
// sun-vector formula), so we assert on the raw inputs here.
float low = System.Math.Min(dawn.DirColor.Y, noon.DirColor.Y);
float high = System.Math.Max(dawn.DirColor.Y, noon.DirColor.Y);
Assert.InRange(midPt.DirColor.Y, low, high);
}
[Fact]
public void RetailSunVector_AtZenith_HasMagnitudeEqualToBrightness()
{
// Sun straight up (P=90°): cos(P)=0, sin(P)=1.
// sunVec = (sin(H)×B×0, 0, B×1) = (0, 0, B)
// |sunVec| = B
var kf = new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
DirColor: Vector3.One,
DirBright: 1.5f,
AmbColor: Vector3.One,
AmbBright: 0.3f,
FogColor: Vector3.One,
FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(kf);
Assert.InRange(v.Length(), 1.49f, 1.51f);
}
[Fact]
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
{
// Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0.
// sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0)
// |sunVec| = 1 regardless of B (because Y is unscaled by B)
var kf = new SkyKeyframe(
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 0f,
DirColor: Vector3.One,
DirBright: 2.0f, // anything
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.One,
FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(kf);
Assert.InRange(v.Length(), 0.99f, 1.01f);
}
[Fact]
public void SunColor_UsesRetailMagnitudeNotDirBrightDirectly()
{
// At sun pitch 90° (zenith) with H=0, B=2: |sunVec| = 2.
// SunColor = DirColor × |sunVec| = (0.5, 0.5, 0.5) × 2 = (1, 1, 1).
var kf = new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
DirColor: new Vector3(0.5f, 0.5f, 0.5f),
DirBright: 2.0f,
AmbColor: Vector3.One,
AmbBright: 0.3f,
FogColor: Vector3.One,
FogDensity: 0f);
Assert.InRange(kf.SunColor.X, 0.99f, 1.01f);
Assert.InRange(kf.SunColor.Y, 0.99f, 1.01f);
Assert.InRange(kf.SunColor.Z, 0.99f, 1.01f);
}
[Fact]
public void AmbientColor_BoostsByTwentyPercentOfSunVectorLength()
{
// |sunVec| = 1 (horizon north), AmbBright = 0.4, AmbColor = (1,1,1).
// AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)
// = (1,1,1) × (0.4 + 0.2) = (0.6, 0.6, 0.6).
var kf = new SkyKeyframe(
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 0f,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 0.4f,
FogColor: Vector3.One,
FogDensity: 0f);
Assert.InRange(kf.AmbientColor.X, 0.59f, 0.61f);
}
[Fact]
@ -56,8 +144,10 @@ public sealed class SkyStateTests
Begin: 0.5f,
SunHeadingDeg: 180f, // south
SunPitchDeg: 70f,
SunColor: Vector3.One,
AmbientColor: Vector3.One,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.One,
FogDensity: 0.001f);

View file

@ -58,8 +58,10 @@ public sealed class WorldTimeDebugTests
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
SunColor: System.Numerics.Vector3.One,
AmbientColor: System.Numerics.Vector3.One,
DirColor: System.Numerics.Vector3.One,
DirBright: 1f,
AmbColor: System.Numerics.Vector3.One,
AmbBright: 1f,
FogColor: System.Numerics.Vector3.Zero,
FogDensity: 0f),
});