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;
///
/// One sky object (celestial mesh) per r12 §2. Each object has:
///
/// - A visibility window in day-fraction space.
/// - A BeginAngle/EndAngle sweep — the arc it traces across the sky during its window.
/// - A texture-velocity pair for UV scrolling (cloud drift, star twinkle).
/// - A GfxObj mesh (the actual geometry rendered at large distance).
///
///
///
/// This is the in-memory mirror of DatReaderWriter.Types.SkyObject
/// scrubbed of dat-reader dependencies and with a couple of derived
/// fields pre-computed. The per-keyframe
/// (r12 §2.3) lives off the owning .
///
///
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 PesObjectId;
public uint Properties;
///
/// True when this SkyObject is gated on the weather system (Properties
/// bit 0x04). Per the named retail decomp,
/// GameSky::CreateDeletePhysicsObjects at 0x005073c0
/// passes Properties & 4 as arg5 of
/// GameSky::MakeObject (0x00506ee0); the inner
/// (arg5 == 0 || LScape::weather_enabled != 0) guard at decomp
/// line 268630 means weather-flagged objects only get instantiated when
/// the global weather flag is on. This bit does not control
/// pre/post-scene placement — that's .
/// acdream currently always renders weather-flagged objects (we don't
/// honor a weather_enabled toggle yet); when we add one, this flag is
/// the gate.
///
public bool IsWeather => (Properties & 0x04u) != 0u;
///
/// True when this SkyObject renders after the world scene
/// (Properties bit 0x01) — i.e. as foreground over terrain and
/// entities. Per the named retail decomp,
/// GameSky::CreateDeletePhysicsObjects passes
/// Properties & 1 as arg4 of
/// GameSky::MakeObject (decomp line 269036); MakeObject at
/// decomp 268656 routes arg4 != 0 objects into
/// after_sky_cell instead of before_sky_cell, and
/// GameSky::Draw(arg2=1) at 0x00506ff0 draws
/// after_sky_cell as a separate post-scene pass.
///
/// In Dereth's Rainy DayGroup this distinguishes the two rain
/// cylinders: 0x01004C44 (Props=0x05) is foreground rain
/// rendered after terrain; 0x01004C42 (Props=0x04 alone) is
/// background rain rendered with the sky dome. Earlier
/// versions of acdream incorrectly split on
/// (bit 0x04) so both rain meshes ended up in the post-scene pass,
/// double-rendering rain in the foreground.
///
///
public bool IsPostScene => (Properties & 0x01u) != 0u;
/// Object is visible at day-fraction
/// by retail's begin/end semantics (r12 §2). Three cases:
///
/// - Begin == End → always visible.
/// - Begin < End → daytime arc, visible in [Begin, End].
/// - Begin > End → wraps midnight, visible in [Begin, 1) ∪ [0, End].
///
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;
}
///
/// Arc progress 0..1 through the visibility window; gives the angle
/// interpolation for BeginAngle→EndAngle (r12 §2).
///
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);
}
///
/// Current arc angle in degrees given the day fraction. Linear
/// interpolation between and .
///
public float CurrentAngle(float t)
{
if (BeginTime == EndTime) return BeginAngle;
return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
}
}
///
/// Per-keyframe override for one sky object — swap its mesh at dusk,
/// dim it, or rotate it (r12 §2.3). Indexed by
/// into the owning day group's SkyObjects list.
///
public sealed class SkyObjectReplaceData
{
public uint ObjectIndex;
public uint GfxObjId;
public float Rotate;
public float Transparent;
public float Luminosity;
public float MaxBright;
}
///
/// Full lighting + sky-object-override data for one SkyTimeOfDay
/// keyframe. Built alongside the the shaders
/// consume — this form keeps the per-object overrides which the
/// SkyRenderer needs to swap clouds for overcast keyframes.
///
public sealed class DatSkyKeyframeData
{
public SkyKeyframe Keyframe;
public IReadOnlyList Replaces = Array.Empty();
}
///
/// One DayGroup 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.
///
public sealed class DayGroupData
{
public float ChanceOfOccur;
public string Name = "";
public IReadOnlyList SkyObjects = Array.Empty();
public IReadOnlyList SkyTimes = Array.Empty();
}
///
/// 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:
///
/// - A ready to drop into .
/// - A list of day groups for weather picking.
/// - Calendar constants (DayLength, etc) for cross-checking.
///
///
public sealed class LoadedSkyDesc
{
public double TickSize;
public double LightTickSize;
public IReadOnlyList DayGroups = Array.Empty();
///
/// Pick a deterministically for the given
/// Derethian (year, dayOfYear) pair. Retail-verbatim port of
/// SkyDesc::PickCurrentDayGroup (FUN_00501990 at
/// chunk_00500000.c:1276) — the per-frame weather roller.
///
///
/// Algorithm (from the retail decompile):
///
/// 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
///
/// It is uniform over all DayGroups — retail does NOT weight
/// by . Dereth's 20 groups all
/// carry ChanceOfOccur=5.0 so the intent is equiprobable anyway. See
/// docs/research/2026-04-23-daygroup-selection.md §2-5 for the
/// full citation trail and the disproof of the weighted-CDF
/// hypothesis.
///
///
///
/// should be the dat-declared
/// "seconds per Derethian day" integer (retail reads it from
/// TimeOfDay + 0x10). acdream's callers pass
/// as an int (7620); ACE
/// computes the same value server-side so retail and acdream
/// converge on identical picks whenever their (Year, DayOfYear)
/// agree — which they do, because both derive from the server's
/// PortalYearTicks.
///
///
///
/// The ACDREAM_DAY_GROUP environment variable overrides the
/// pick (useful for visually A/B-testing each weather preset against
/// retail).
///
///
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;
}
///
/// Convenience: derive (Year, DayOfYear) from the current
/// and call .
/// Used by the per-frame render tick so callers don't need to
/// de-structure the calendar themselves.
///
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;
}
///
/// Legacy accessor — kept for callers that don't yet know the server
/// tick count. Rolls against (year=0, dayOfYear=0) so env-var
/// override works but unforced selection always returns the same
/// group pre-sync. Prefer which
/// syncs to the server clock.
///
public DayGroupData? DefaultDayGroup
{
get
{
int idx = SelectDayGroupIndex(
year: 0,
secondsPerDay: DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear, // 360
dayOfYear: 0);
return DayGroups.Count > 0 ? DayGroups[idx] : null;
}
}
///
/// Build a shader-facing for the
/// default day group. For retail-faithful day-rolling, rebuild this
/// whenever changes (once per Dereth-day).
///
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());
}
///
/// Build a for the DayGroup rolled for
/// . Call on day rollover to swap the
/// interpolator to the new weather regime.
///
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());
}
}
///
/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
/// Safe to call off the render thread as long as the underlying
/// isn't being mutated (acdream's one-shot
/// startup path already holds the dat lock during Region reads).
///
///
/// Retail stores the entire world's sky + calendar in this single record
/// — there's only ever one Region. The loader reads the SkyDesc
/// out of region.SkyInfo, iterates every DayGroup, and converts
/// each SkyTimeOfDay to our record.
///
///
///
/// The SunColor / AmbientColor fields store the color × brightness
/// product so the shader UBO layout can stay a flat vec3 without
/// extra multiplies per pixel. See r12 §4.
///
///
public static class SkyDescLoader
{
public const uint RegionDatId = 0x13000000u;
///
/// Load + parse. Returns null if the Region doesn't have
/// or the dat is absent.
///
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var region = dats.Get(RegionDatId);
if (region is null) return null;
return LoadFromRegion(region);
}
///
/// Convert an in-memory Region object to our domain data.
/// Separated so tests can feed hand-built Regions without the dat
/// pipeline.
///
///
/// Set ACDREAM_DUMP_SKY=1 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
/// docs/research/2026-04-23-sky-retail-verbatim.md — the
/// dump resolves the open questions about Transparent/Luminosity/
/// MaxBright unit (percent vs fraction) and the per-keyframe
/// GfxObjReplace swap pattern.
///
///
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(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,
};
}
///
/// 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:
///
/// - The unit of Transparent/Luminosity/MaxBright
/// (if consistently >1, they're percent — our /100 divide is correct;
/// if consistently in [0,1], they're fractions and the divide is wrong).
/// - Which DayGroup keyframes actually swap the
/// GfxObj (non-zero GfxObjId) and which just tweak brightness.
/// - The full Dereth sky-object inventory —
/// index-to-role mapping (sun/moon/dome/clouds/stars).
///
/// Logs to stdout with the prefix [sky-dump]. Gate with
/// ACDREAM_DUMP_SKY=1.
///
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,
PesObjectId = s.DefaultPesObjectId?.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,
};
}
///
/// 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 . Alpha is ignored (retail lighting
/// doesn't use it).
///
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);
}
}