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>
595 lines
26 KiB
C#
595 lines
26 KiB
C#
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 & 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 < End</c> → daytime arc, visible in [Begin, End].</description></item>
|
||
/// <item><description><c>Begin > 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 >= 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 >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);
|
||
}
|
||
}
|