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 Properties; /// 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 BeginAngleEndAngle (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 year = DerethDateTime.Year(serverTicks); int dayOfYear = DerethDateTime.DayOfYear(serverTicks); int secondsPerDay = (int)DerethDateTime.DayTicks; // 7620 int idx = SelectDayGroupIndex(year, 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: (int)DerethDateTime.DayTicks, 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}\""); 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, }; var kf = new SkyKeyframe( Begin: s.Begin, SunHeadingDeg: s.DirHeading, SunPitchDeg: s.DirPitch, SunColor: ColorToVec3(s.DirColor) * s.DirBright, AmbientColor: ColorToVec3(s.AmbColor) * 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); } }