acdream/src/AcDream.Core/World/SkyDescLoader.cs
Erik 6ea87b7ea8 sky(phase-3c): port retail FUN_00501990 DayGroup picker (uniform LCG)
Decompile agent located the retail DayGroup selection function at
FUN_00501990 (chunk_00500000.c:1276). It is a straight-line 32-bit
signed LCG — NOT a ChanceOfOccur-weighted CDF. Replaces the SplitMix64
approximation from Phase 3a.

Algorithm (verbatim from the decompile):

  seed  = year * secondsPerDay + dayOfYear    // TimeOfDay+0x64/+0x10/+0x68
  hash  = seed * 0x6A42FDB2 + 0x8ABE1652      // signed 32-bit LCG
  index = floor(dayGroupCount * (uint)hash / 2^32)
  if (index >= dayGroupCount) index = 0       // float-rounding safety

Uniform over all DayGroups. Dereth's 20 groups all carry ChanceOfOccur=5.0
so uniform matches the statistical intent; the weighted walk Phase 3a
attempted is NOT what retail does. The SecondsPerDay multiplier is
load-bearing — without it, adjacent years would share adjacent LCG
seeds and divergence from retail would recur annually.

Result (this session's local ACE):
  server: PY106 ColdMeet 17 MorntideAndHalf, ticks=291130073
  → year=106, dayOfYear=(106×0 + 17 across ColdMeet) via DerethDateTime
  → retail picker returns a deterministic uniform index from LCG.
  Acdream and retail now agree on the pick for any (Year, DayOfYear)
  since both drive from the same server PortalYearTicks.

Changes:
- src/AcDream.Core/World/DerethDateTime.cs: add Year(ticks) and
  DayOfYear(ticks) helpers (match retail TimeOfDay+0x64 / +0x68).
- src/AcDream.Core/World/SkyDescLoader.cs:
  - SelectDayGroupIndex signature: (year, secondsPerDay, dayOfYear)
    instead of the flat dayIndex used by the SplitMix64 approximation.
  - Body: retail LCG line-by-line port with decompile citations.
  - ACDREAM_DAY_GROUP env var still overrides (for A/B verification).
- src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay now
  feeds Year / DayOfYear / SecondsPerDay=7620 to the picker instead
  of a flat dayIndex. Composite `year*360+dayOfYear` still tracked
  internally as the day-change key for provider-rebuild idempotence.
- docs/research/2026-04-23-daygroup-selection.md committed with the
  full decompile trail (new agent-produced research).

Build + 717 tests green. User visual verification (retail side-by-side)
next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:45:34 +02:00

527 lines
22 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>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 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;
}
/// <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: (int)DerethDateTime.DayTicks, 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}\"");
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,
};
}
/// <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);
}
}