acdream/src/AcDream.Core/World/SkyDescLoader.cs
Erik 63b50c5291 fix(sky): retail-faithful keyframe lerp — separate-channel color/bright
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:

  arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
  arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
  arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
  arg3   = lerp(k1.amb_bright, k2.amb_bright, u)
  final  = (arg4.rgb * arg3, ...)

acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
  - retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
  - acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)

For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.

Refactor:
  SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
    SEPARATELY (raw, not pre-multiplied).
  Computed properties SunColor and AmbientColor return the
    post-multiplied product, keeping the shader uniform interface
    (uSunColor / uAmbientColor) unchanged.
  SkyStateProvider.Interpolate lerps each raw channel, then constructs
    a new SkyKeyframe whose computed properties yield the correct
    post-lerp multiply.
  SkyDescLoader now stores raw values without pre-multiplying.
  GameWindow comment updated; no functional change there.
  Default factory + tests updated to use the new constructor parameters
    with DirBright=AmbBright=1.0 (preserving exact existing behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:02:35 +02:00

595 lines
26 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.Linq;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.World;
/// <summary>
/// One sky object (celestial mesh) per r12 §2. Each object has:
/// <list type="bullet">
/// <item><description>A visibility window in day-fraction space.</description></item>
/// <item><description>A <c>BeginAngle</c>/<c>EndAngle</c> sweep — the arc it traces across the sky during its window.</description></item>
/// <item><description>A texture-velocity pair for UV scrolling (cloud drift, star twinkle).</description></item>
/// <item><description>A GfxObj mesh (the actual geometry rendered at large distance).</description></item>
/// </list>
///
/// <para>
/// This is the in-memory mirror of <c>DatReaderWriter.Types.SkyObject</c>
/// scrubbed of dat-reader dependencies and with a couple of derived
/// fields pre-computed. The per-keyframe <see cref="SkyObjectReplaceData"/>
/// (r12 §2.3) lives off the owning <see cref="DatSkyKeyframeData"/>.
/// </para>
/// </summary>
public sealed class SkyObjectData
{
public float BeginTime;
public float EndTime;
public float BeginAngle;
public float EndAngle;
public float TexVelocityX;
public float TexVelocityY;
public uint GfxObjId;
public uint Properties;
/// <summary>
/// True when this SkyObject is flagged as weather (Properties bit
/// <c>0x04</c>). Per the named retail decomp,
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>
/// passes <c>Properties &amp; 0x04</c> as <c>arg5</c> of
/// <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>) — when set, the
/// CPhysicsObj is added to <c>after_sky_cell</c> instead of
/// <c>before_sky_cell</c>, and <c>GameSky::Draw(arg2=1)</c> at
/// <c>0x00506ff0</c> draws that cell <i>after</i> the scene. acdream
/// uses this flag to split the sky pass: non-weather objects render
/// pre-scene (so terrain and entities z-test on top), weather meshes
/// (e.g. the 815m-tall rain cylinders <c>0x01004C42</c>/<c>0x01004C44</c>)
/// render post-scene with depth-test off so they overlay foreground
/// geometry — matching retail's volumetric foreground-rain look.
/// </summary>
public bool IsWeather => (Properties & 0x04u) != 0u;
/// <summary>Object is visible at day-fraction <paramref name="t"/>
/// by retail's begin/end semantics (r12 §2). Three cases:
/// <list type="bullet">
/// <item><description><c>Begin == End</c> → always visible.</description></item>
/// <item><description><c>Begin &lt; End</c> → daytime arc, visible in [Begin, End].</description></item>
/// <item><description><c>Begin &gt; End</c> → wraps midnight, visible in [Begin, 1) [0, End].</description></item>
/// </list></summary>
public bool IsVisible(float t)
{
if (BeginTime == EndTime) return true;
if (BeginTime < EndTime) return t >= BeginTime && t <= EndTime;
// Wrap around midnight.
return t >= BeginTime || t <= EndTime;
}
/// <summary>
/// Arc progress 0..1 through the visibility window; gives the angle
/// interpolation for <c>BeginAngle</c>→<c>EndAngle</c> (r12 §2).
/// </summary>
public float AngleProgress(float t)
{
if (BeginTime == EndTime) return 0f;
float duration;
float progress;
if (BeginTime < EndTime)
{
duration = EndTime - BeginTime;
progress = (t - BeginTime) / duration;
}
else
{
duration = (1f - BeginTime) + EndTime;
progress = (t >= BeginTime)
? (t - BeginTime) / duration
: (t + (1f - BeginTime)) / duration;
}
return Math.Clamp(progress, 0f, 1f);
}
/// <summary>
/// Current arc angle in degrees given the day fraction. Linear
/// interpolation between <see cref="BeginAngle"/> and <see cref="EndAngle"/>.
/// </summary>
public float CurrentAngle(float t)
{
if (BeginTime == EndTime) return BeginAngle;
return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
}
}
/// <summary>
/// Per-keyframe override for one sky object — swap its mesh at dusk,
/// dim it, or rotate it (r12 §2.3). Indexed by
/// <see cref="ObjectIndex"/> into the owning day group's SkyObjects list.
/// </summary>
public sealed class SkyObjectReplaceData
{
public uint ObjectIndex;
public uint GfxObjId;
public float Rotate;
public float Transparent;
public float Luminosity;
public float MaxBright;
}
/// <summary>
/// Full lighting + sky-object-override data for one <c>SkyTimeOfDay</c>
/// keyframe. Built alongside the <see cref="SkyKeyframe"/> the shaders
/// consume — this form keeps the per-object overrides which the
/// <c>SkyRenderer</c> needs to swap clouds for overcast keyframes.
/// </summary>
public sealed class DatSkyKeyframeData
{
public SkyKeyframe Keyframe;
public IReadOnlyList<SkyObjectReplaceData> Replaces = Array.Empty<SkyObjectReplaceData>();
}
/// <summary>
/// One <c>DayGroup</c> from retail's Region dat — a self-contained
/// weather regime. Retail Dereth ships ~3 day groups (clear, overcast,
/// storm) and the client rolls one per day. r12 §11 describes this.
/// </summary>
public sealed class DayGroupData
{
public float ChanceOfOccur;
public string Name = "";
public IReadOnlyList<SkyObjectData> SkyObjects = Array.Empty<SkyObjectData>();
public IReadOnlyList<DatSkyKeyframeData> SkyTimes = Array.Empty<DatSkyKeyframeData>();
}
/// <summary>
/// Fully-loaded skybox data pulled from the Region dat (0x13000000).
/// Has everything the renderer + weather system need to produce a
/// retail-faithful day/night cycle:
/// <list type="bullet">
/// <item><description>A <see cref="SkyStateProvider"/> ready to drop into <see cref="WorldTimeService"/>.</description></item>
/// <item><description>A list of day groups for weather picking.</description></item>
/// <item><description>Calendar constants (<c>DayLength</c>, etc) for cross-checking.</description></item>
/// </list>
/// </summary>
public sealed class LoadedSkyDesc
{
public double TickSize;
public double LightTickSize;
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
/// <summary>
/// Pick a <see cref="DayGroupData"/> deterministically for the given
/// Derethian (year, dayOfYear) pair. Retail-verbatim port of
/// <c>SkyDesc::PickCurrentDayGroup</c> (<c>FUN_00501990</c> at
/// <c>chunk_00500000.c:1276</c>) — the per-frame weather roller.
///
/// <para>
/// <b>Algorithm</b> (from the retail decompile):
/// <code>
/// seed = year * secondsPerDay + dayOfYear
/// hash = seed * 0x6A42FDB2 + 0x8ABE1652 (signed 32-bit LCG)
/// index = floor(dayGroupCount * (uint)hash / 2^32)
/// if (index &gt;= dayGroupCount) index = 0 // float-rounding safety
/// </code>
/// It is <b>uniform</b> over all DayGroups — retail does NOT weight
/// by <see cref="DayGroupData.ChanceOfOccur"/>. Dereth's 20 groups all
/// carry ChanceOfOccur=5.0 so the intent is equiprobable anyway. See
/// <c>docs/research/2026-04-23-daygroup-selection.md</c> §2-5 for the
/// full citation trail and the disproof of the weighted-CDF
/// hypothesis.
/// </para>
///
/// <para>
/// <paramref name="secondsPerDay"/> should be the dat-declared
/// "seconds per Derethian day" integer (retail reads it from
/// <c>TimeOfDay + 0x10</c>). acdream's callers pass
/// <see cref="DerethDateTime.DayTicks"/> as an int (7620); ACE
/// computes the same value server-side so retail and acdream
/// converge on identical picks whenever their <c>(Year, DayOfYear)</c>
/// agree — which they do, because both derive from the server's
/// <c>PortalYearTicks</c>.
/// </para>
///
/// <para>
/// The <c>ACDREAM_DAY_GROUP</c> environment variable overrides the
/// pick (useful for visually A/B-testing each weather preset against
/// retail).
/// </para>
/// </summary>
public int SelectDayGroupIndex(int year, int secondsPerDay, int dayOfYear)
{
if (DayGroups.Count == 0) return 0;
// Env-var override has absolute priority.
var env = System.Environment.GetEnvironmentVariable("ACDREAM_DAY_GROUP");
if (int.TryParse(env, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var forced)
&& forced >= 0 && forced < DayGroups.Count)
{
return forced;
}
if (DayGroups.Count == 1) return 0;
// --- Retail FUN_00501990 line-by-line port ---
// Step 1: deterministic per-day seed.
int seed = unchecked(year * secondsPerDay + dayOfYear);
// Step 2: 32-bit signed LCG (retail uses x86 silent wrap; force
// unchecked in C#). `0x8ABE1652` is stored as `-0x7541E9AE` in the
// decompile — same bit pattern.
int mixed = unchecked(seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652));
// Step 3: signed-int → float with +2^32 fixup for negative values
// (retail x87 converts `int` to `float` and then adds
// `_DAT_0079920c` ≈ 4294967296.0f when `iVar4 < 0`).
float hashF = (float)mixed;
if (mixed < 0) hashF += 4294967296.0f;
// Step 4: scale to [0, dayGroupCount). `_DAT_007c6f10` is
// `1.0f / 2^32` per reuse pattern (see research doc §2.4).
const float kInv2Pow32 = 1.0f / 4294967296.0f;
float countF = (float)DayGroups.Count;
int index = (int)System.MathF.Floor(countF * hashF * kInv2Pow32);
// Step 5: safety clamp for float rounding at the upper edge.
// Retail does `if (count <= index) index = 0;`. Using the same
// "snap to 0 on overflow" instead of clamping to count-1.
if (index >= DayGroups.Count) index = 0;
if (index < 0) index = 0;
return index;
}
/// <summary>
/// Convenience: derive (Year, DayOfYear) from the current
/// <paramref name="serverTicks"/> and call <see cref="SelectDayGroupIndex"/>.
/// Used by the per-frame render tick so callers don't need to
/// de-structure the calendar themselves.
/// </summary>
public DayGroupData? ActiveDayGroup(double serverTicks)
{
int absYear = DerethDateTime.AbsoluteYear(serverTicks);
int dayOfYear = DerethDateTime.DayOfYear(serverTicks);
// Retail's TimeOfDay+0x10 is actually DaysPerYear (= 360 for Dereth,
// live probe 2026-04-23), NOT SecondsPerDay as the decompile agent
// mis-labeled. See GameWindow.RefreshSkyForCurrentDay for the full
// citation.
int secondsPerDay = DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear; // 360
int idx = SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
return idx < DayGroups.Count ? DayGroups[idx] : null;
}
/// <summary>
/// Legacy accessor — kept for callers that don't yet know the server
/// tick count. Rolls against <c>(year=0, dayOfYear=0)</c> so env-var
/// override works but unforced selection always returns the same
/// group pre-sync. Prefer <see cref="ActiveDayGroup(double)"/> which
/// syncs to the server clock.
/// </summary>
public DayGroupData? DefaultDayGroup
{
get
{
int idx = SelectDayGroupIndex(
year: 0,
secondsPerDay: DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear, // 360
dayOfYear: 0);
return DayGroups.Count > 0 ? DayGroups[idx] : null;
}
}
/// <summary>
/// Build a shader-facing <see cref="SkyStateProvider"/> for the
/// default day group. For retail-faithful day-rolling, rebuild this
/// whenever <see cref="ActiveDayGroup"/> changes (once per Dereth-day).
/// </summary>
public SkyStateProvider BuildDefaultProvider()
{
var grp = DefaultDayGroup;
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
}
/// <summary>
/// Build a <see cref="SkyStateProvider"/> for the DayGroup rolled for
/// <paramref name="serverTicks"/>. Call on day rollover to swap the
/// interpolator to the new weather regime.
/// </summary>
public SkyStateProvider BuildProviderForDay(double serverTicks)
{
var grp = ActiveDayGroup(serverTicks);
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
}
}
/// <summary>
/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
/// Safe to call off the render thread as long as the underlying
/// <see cref="DatCollection"/> isn't being mutated (acdream's one-shot
/// startup path already holds the dat lock during Region reads).
///
/// <para>
/// Retail stores the entire world's sky + calendar in this single record
/// — there's only ever one <c>Region</c>. The loader reads the SkyDesc
/// out of <c>region.SkyInfo</c>, iterates every DayGroup, and converts
/// each <c>SkyTimeOfDay</c> to our <see cref="SkyKeyframe"/> record.
/// </para>
///
/// <para>
/// The SunColor / AmbientColor fields store the color × brightness
/// product so the shader UBO layout can stay a flat <c>vec3</c> without
/// extra multiplies per pixel. See r12 §4.
/// </para>
/// </summary>
public static class SkyDescLoader
{
public const uint RegionDatId = 0x13000000u;
/// <summary>
/// Load + parse. Returns <c>null</c> if the Region doesn't have
/// <see cref="PartsMask.HasSkyInfo"/> or the dat is absent.
/// </summary>
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var region = dats.Get<Region>(RegionDatId);
if (region is null) return null;
return LoadFromRegion(region);
}
/// <summary>
/// Convert an in-memory Region object to our domain data.
/// Separated so tests can feed hand-built Regions without the dat
/// pipeline.
///
/// <para>
/// Set <c>ACDREAM_DUMP_SKY=1</c> in the environment to log the
/// entire decoded SkyDesc (raw dat values, pre-/100 divide) to
/// stdout on load. Paired with the decompile research in
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> — the
/// dump resolves the open questions about Transparent/Luminosity/
/// MaxBright unit (percent vs fraction) and the per-keyframe
/// GfxObjReplace swap pattern.
/// </para>
/// </summary>
public static LoadedSkyDesc? LoadFromRegion(Region region)
{
ArgumentNullException.ThrowIfNull(region);
if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
return null;
var sky = region.SkyInfo;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpRegionSkyDesc(region);
var dayGroups = new List<DayGroupData>(sky.DayGroups.Count);
foreach (var dg in sky.DayGroups)
{
var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
dayGroups.Add(new DayGroupData
{
ChanceOfOccur = dg.ChanceOfOccur,
Name = dg.DayName?.ToString() ?? "",
SkyObjects = objs,
SkyTimes = times,
});
}
return new LoadedSkyDesc
{
TickSize = sky.TickSize,
LightTickSize = sky.LightTickSize,
DayGroups = dayGroups,
};
}
/// <summary>
/// One-shot diagnostic dump of the retail Region's SkyDesc. Prints
/// every DayGroup, SkyObject, SkyTimeOfDay, and SkyObjectReplace
/// with RAW dat values (before any unit transform) so we can compare
/// against the retail decompile field layouts and resolve:
/// <list type="bullet">
/// <item><description>The unit of <c>Transparent</c>/<c>Luminosity</c>/<c>MaxBright</c>
/// (if consistently &gt;1, they're percent — our <c>/100</c> divide is correct;
/// if consistently in [0,1], they're fractions and the divide is wrong).</description></item>
/// <item><description>Which DayGroup keyframes actually swap the
/// GfxObj (non-zero <c>GfxObjId</c>) and which just tweak brightness.</description></item>
/// <item><description>The full Dereth sky-object inventory —
/// index-to-role mapping (sun/moon/dome/clouds/stars).</description></item>
/// </list>
/// Logs to stdout with the prefix <c>[sky-dump]</c>. Gate with
/// <c>ACDREAM_DUMP_SKY=1</c>.
/// </summary>
private static void DumpRegionSkyDesc(Region region)
{
var sky = region.SkyInfo;
if (sky is null) return;
Console.WriteLine("[sky-dump] ======== BEGIN SkyDesc dump ========");
Console.WriteLine($"[sky-dump] Region Id={region.Id:X8} Number={region.RegionNumber} Name=\"{region.RegionName}\"");
// Phase 3f diag — retail TimeOfDay::OnTick uses
// GameTime.ZeroTimeOfYear as an additive tick offset before
// calendar extraction. Our DerethDateTime currently hardcodes
// +7/16 (= 3333.75) for the dayFraction math and IGNORES this
// field. If the dat value is anything other than what we assume,
// our calendar is skewed from retail's at every moment.
var gt = region.GameTime;
if (gt is not null)
{
Console.WriteLine(
$"[sky-dump] GameTime ZeroTimeOfYear={gt.ZeroTimeOfYear} ZeroYear={gt.ZeroYear} " +
$"DayLength={gt.DayLength} DaysPerYear={gt.DaysPerYear} " +
$"YearSpec=\"{gt.YearSpec}\" TimesOfDay.Count={gt.TimesOfDay?.Count ?? 0} " +
$"DaysOfWeek.Count={gt.DaysOfWeek?.Count ?? 0} Seasons.Count={gt.Seasons?.Count ?? 0}");
// Dump every TimeOfDay slot — this is the retail-authoritative
// hour boundary table. Anchors our offset math: if retail
// TimeOfDay[0].Start != our assumed 0, we have our answer.
if (gt.TimesOfDay is not null)
{
for (int i = 0; i < gt.TimesOfDay.Count; i++)
{
var t = gt.TimesOfDay[i];
Console.WriteLine(
$"[sky-dump] TimeOfDay[{i}] Start={t.Start} IsNight={t.IsNight} Name=\"{t.Name}\"");
}
}
}
else
{
Console.WriteLine("[sky-dump] GameTime: null (no calendar info in this region)");
}
Console.WriteLine($"[sky-dump] SkyDesc TickSize={sky.TickSize} LightTickSize={sky.LightTickSize} DayGroups.Count={sky.DayGroups.Count}");
for (int g = 0; g < sky.DayGroups.Count; g++)
{
var dg = sky.DayGroups[g];
Console.WriteLine($"[sky-dump] DayGroup[{g}] Name=\"{dg.DayName}\" Chance={dg.ChanceOfOccur:F3} SkyObjects.Count={dg.SkyObjects.Count} SkyTime.Count={dg.SkyTime.Count}");
for (int i = 0; i < dg.SkyObjects.Count; i++)
{
var o = dg.SkyObjects[i];
uint gfxId = o.DefaultGfxObjectId?.DataId ?? 0u;
uint pesId = o.DefaultPesObjectId?.DataId ?? 0u;
Console.WriteLine(
$"[sky-dump] SkyObject[{i}] GfxObjId=0x{gfxId:X8} PesObjectId=0x{pesId:X8} " +
$"Time=[{o.BeginTime:F4}..{o.EndTime:F4}] Angle=[{o.BeginAngle:F1}°..{o.EndAngle:F1}°] " +
$"TexVel=({o.TexVelocityX:F5},{o.TexVelocityY:F5}) Properties=0x{o.Properties:X8}");
}
for (int k = 0; k < dg.SkyTime.Count; k++)
{
var t = dg.SkyTime[k];
string dirColor = t.DirColor is null ? "null" :
$"({t.DirColor.Red},{t.DirColor.Green},{t.DirColor.Blue},{t.DirColor.Alpha})";
string ambColor = t.AmbColor is null ? "null" :
$"({t.AmbColor.Red},{t.AmbColor.Green},{t.AmbColor.Blue},{t.AmbColor.Alpha})";
string fogColor = t.WorldFogColor is null ? "null" :
$"({t.WorldFogColor.Red},{t.WorldFogColor.Green},{t.WorldFogColor.Blue},{t.WorldFogColor.Alpha})";
Console.WriteLine(
$"[sky-dump] SkyTime[{k}] Begin={t.Begin:F4} " +
$"DirBright={t.DirBright:F4} DirHeading={t.DirHeading:F1}° DirPitch={t.DirPitch:F1}° " +
$"DirColor={dirColor} AmbBright={t.AmbBright:F4} AmbColor={ambColor} " +
$"Fog=[{t.MinWorldFog:F1}m..{t.MaxWorldFog:F1}m] FogColor={fogColor} FogMode={t.WorldFog}");
for (int r = 0; r < t.SkyObjReplace.Count; r++)
{
var rep = t.SkyObjReplace[r];
uint rGfx = rep.GfxObjId?.DataId ?? 0u;
// RAW values — pre-/100 divide. Compare these against the retail
// scale constant _DAT_007a1870 to settle the unit question.
Console.WriteLine(
$"[sky-dump] Replace[{r}] ObjectIndex={rep.ObjectIndex} GfxObjId=0x{rGfx:X8} " +
$"Rotate={rep.Rotate:F3}° Transparent_raw={rep.Transparent:F6} " +
$"Luminosity_raw={rep.Luminosity:F6} MaxBright_raw={rep.MaxBright:F6}");
}
}
}
Console.WriteLine("[sky-dump] ======== END SkyDesc dump ========");
}
private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
{
BeginTime = s.BeginTime,
EndTime = s.EndTime,
BeginAngle = s.BeginAngle,
EndAngle = s.EndAngle,
TexVelocityX = s.TexVelocityX,
TexVelocityY = s.TexVelocityY,
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
Properties = s.Properties,
};
private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
{
// Transparent / Luminosity / MaxBright are stored in the retail
// Region dat as PERCENTAGES (0..100), not fractions (0..1). Our
// shader expects fractions — divide here. Confirmed from live
// diag dump of Region 0x13000000 / DayGroup "Sunny": noon keyframes
// have Luminosity=100 and Transparent=100 across multiple
// SkyObjectReplace entries, corresponding to 1.0 in shader units.
// Previously passing the raw 100 through resulted in
// `rgb = texture * 100` which blew out to pure white everywhere
// (clamped to vec3(1.2) in the sky fragment shader) — this was the
// "white sky at noon" bug observed by the user.
//
// Rotate stays as degrees (270° values in the data are genuinely
// heading-degrees, not percentages). ObjectIndex / GfxObjId are
// IDs with no unit transform.
//
// WorldBuilder's SkyboxRenderManager does NOT apply Luminosity /
// Transparent / MaxBright at all (ignores the fields entirely),
// which is why they never ran into this bug. We apply them for
// per-keyframe day-night fade which retail does.
var replaces = s.SkyObjReplace.Select(r => new SkyObjectReplaceData
{
ObjectIndex = r.ObjectIndex,
GfxObjId = r.GfxObjId?.DataId ?? 0u,
Rotate = r.Rotate,
Transparent = r.Transparent / 100f,
Luminosity = r.Luminosity / 100f,
MaxBright = r.MaxBright / 100f,
}).ToList();
var fogMode = s.WorldFog switch
{
1u => FogMode.Linear,
2u => FogMode.Exp,
3u => FogMode.Exp2,
_ => FogMode.Off,
};
// Store DirColor / AmbColor RAW and DirBright / AmbBright SEPARATE
// (NOT pre-multiplied) so the keyframe interpolator can lerp each
// channel independently — matches retail SkyDesc::GetLighting at
// 0x00500ac9 (decomp lines 261317-261331). Multiplying at load
// time and lerping the product produces mathematically different
// results than retail when both color and brightness change
// between adjacent keyframes. The post-multiplied values are
// available via `kf.SunColor` / `kf.AmbientColor` computed
// properties for shader-uniform plumbing.
var kf = new SkyKeyframe(
Begin: s.Begin,
SunHeadingDeg: s.DirHeading,
SunPitchDeg: s.DirPitch,
DirColor: ColorToVec3(s.DirColor),
DirBright: s.DirBright,
AmbColor: ColorToVec3(s.AmbColor),
AmbBright: s.AmbBright,
FogColor: ColorToVec3(s.WorldFogColor),
FogDensity: 0f,
FogStart: s.MinWorldFog,
FogEnd: s.MaxWorldFog,
FogMode: fogMode);
return new DatSkyKeyframeData
{
Keyframe = kf,
Replaces = replaces,
};
}
/// <summary>
/// <see cref="ColorARGB"/> stores bytes as B,G,R,A — but the logical
/// channel mapping is just "R/G/B in 0..255". Convert to linear
/// 0..1 <see cref="Vector3"/>. Alpha is ignored (retail lighting
/// doesn't use it).
/// </summary>
public static Vector3 ColorToVec3(ColorARGB? c)
{
if (c is null) return Vector3.One;
return new Vector3(c.Red / 255f, c.Green / 255f, c.Blue / 255f);
}
}