Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
619 lines
27 KiB
C#
619 lines
27 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 PesObjectId;
|
||
public uint Properties;
|
||
|
||
/// <summary>
|
||
/// True when this SkyObject is gated on the weather system (Properties
|
||
/// bit <c>0x04</c>). Per the named retail decomp,
|
||
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>
|
||
/// passes <c>Properties & 4</c> as <c>arg5</c> of
|
||
/// <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>); the inner
|
||
/// <c>(arg5 == 0 || LScape::weather_enabled != 0)</c> guard at decomp
|
||
/// line 268630 means weather-flagged objects only get instantiated when
|
||
/// the global weather flag is on. This bit does <b>not</b> control
|
||
/// pre/post-scene placement — that's <see cref="IsPostScene"/>.
|
||
/// 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.
|
||
/// </summary>
|
||
public bool IsWeather => (Properties & 0x04u) != 0u;
|
||
|
||
/// <summary>
|
||
/// True when this SkyObject renders <i>after</i> the world scene
|
||
/// (Properties bit <c>0x01</c>) — i.e. as foreground over terrain and
|
||
/// entities. Per the named retail decomp,
|
||
/// <c>GameSky::CreateDeletePhysicsObjects</c> passes
|
||
/// <c>Properties & 1</c> as <c>arg4</c> of
|
||
/// <c>GameSky::MakeObject</c> (decomp line 269036); MakeObject at
|
||
/// decomp 268656 routes <c>arg4 != 0</c> objects into
|
||
/// <c>after_sky_cell</c> instead of <c>before_sky_cell</c>, and
|
||
/// <c>GameSky::Draw(arg2=1)</c> at <c>0x00506ff0</c> draws
|
||
/// <c>after_sky_cell</c> as a separate post-scene pass.
|
||
/// <para>
|
||
/// In Dereth's Rainy DayGroup this distinguishes the two rain
|
||
/// cylinders: <c>0x01004C44</c> (Props=0x05) is foreground rain
|
||
/// rendered after terrain; <c>0x01004C42</c> (Props=0x04 alone) is
|
||
/// background rain rendered <i>with</i> the sky dome. Earlier
|
||
/// versions of acdream incorrectly split on <see cref="IsWeather"/>
|
||
/// (bit 0x04) so both rain meshes ended up in the post-scene pass,
|
||
/// double-rendering rain in the foreground.
|
||
/// </para>
|
||
/// </summary>
|
||
public bool IsPostScene => (Properties & 0x01u) != 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,
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|