fix #28: port retail's sky default-script playback (aurora) and the particle facing law

The aurora was never missing data — it was a missing mechanism plus a
misread. New decompile evidence closes the April-2026 contradiction:
retail plays the sky carriers' PES through the Setup's own DefaultScript
(GameSky::MakeObject @0x00506EE0 -> CPhysicsObj::makeObject @0x00513970
sets state|=0x80000; animate_static_object @0x00513DF0 ticks
ScriptManager + ParticleManager). The pes_id column stays dead — that
half of the April finding stands; the ids are byte-equal mirrors.

- SkyPesFrameController is now the production owner (ACDREAM_ENABLE_SKY_PES
  deleted): script ids resolve from the Setup DefaultScript
  (SkyObjectData.DefaultScriptId; the pes_id column is a one-time-logged
  cross-check), slots persist by (index, gfx id, properties) per
  CreateDeletePhysicsObjects @0x005073C0 — a day-group swap keeping the
  carrier no longer restarts its emitters — and stale slots stop before
  replacements claim the slot-derived owner id.
- RetailParticleFacing ports calc_draw_frame @0x0050DFA0: degrade mode 2
  faces the viewer roll-free (set_vector_heading) instead of the camera
  plane; modes 3/4/5 spin the authored frame around one local axis
  (rotate_around_axis_to_vector) — Dereth authors 54 mode-5 emitters that
  previously got no facing at all; 1,583 mode-2 emitters get the exact
  law; authored/mode-1 paths are unchanged.
- The 2026-08-23 'whole-sky tint' was the Rainy-group lightning/thunder
  PES playing at the debug anchor inside their 0.03-0.19 window, not the
  aurora: the aurora is nine faint viewer-facing glows pulsing on
  6.7/15/55-minute rebirth cycles, in every day group, all day.

Research: docs/research/2026-08-23-sky-default-script-port.md.
Register: AD-112 filed (camera-anchored synthetic owners vs sky-cell
physics objects). ISSUES #2 corrected (the playback ban is lifted by the
new evidence); #28 fix landed pending the connected night gate.
Tests: RetailParticleFacingTests (16), SkyPesFrameControllerTests (6);
hermetic suites App 6,076/0, Core 4,905/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 15:28:59 +02:00
parent 6f47740af0
commit 18fce7bb5a
13 changed files with 906 additions and 113 deletions

View file

@ -329,7 +329,8 @@ internal sealed class FrameRootCompositionPhase
content.ScriptRunner,
content.ParticleSink,
d.EffectPoses,
live.EntityEffects);
live.EntityEffects,
d.Log);
IWorldSceneFramePhase? worldSceneRenderer = null;
CurrentRenderSceneOracle? currentRenderSceneOracle =
interaction.RetainedUi?.Screenshots is not null

View file

@ -105,6 +105,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
private readonly Dictionary<int, ParticleGfxInfo> _particleGfxInfoByEmitter = new();
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
private readonly Dictionary<uint, uint?> _firstDegradeModeByGfxObj = new();
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
private readonly ParticleMeshReferenceTracker? _meshReferences;
private readonly ParticleEmitterRetirementTracker _emitterRetirements;
@ -400,18 +401,75 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
var key = new BatchKey(additive);
Vector3 axisX;
Vector3 axisY;
Vector3 toViewer = cameraWorldPos - pos;
float toViewerLength = toViewer.Length();
if (gfxInfo.IsBillboard)
{
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
axisX = cameraRight * (gfxInfo.Size.X * p.Size);
axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
// Degrade mode 2 — face the viewer roll-free
// (CPhysicsPart::calc_draw_frame @0x0050DFA0 via
// Frame::set_vector_heading), not the camera plane: the two
// agree at screen centre and diverge toward the edges and
// overhead, where retail's sprites tilt toward the viewer.
Vector3 xd;
Vector3 yd;
if (toViewerLength > 1e-3f)
{
(xd, yd) = RetailParticleFacing.OrientQuad(
2u,
Quaternion.Identity,
Vector3.UnitX,
Vector3.UnitY,
toViewer / toViewerLength,
cameraRight,
cameraUp);
}
else
{
(xd, yd) = (cameraRight, cameraUp);
}
// The sprite's authored (X, Z) plane rides the quad axes; the
// out-of-plane component (authored Y, ~0 on flat sprites) is
// dropped rather than pushed along the view direction.
pos += (xd * gfxInfo.CenterOffset.X
+ yd * gfxInfo.CenterOffset.Z) * p.Size;
axisX = xd * (gfxInfo.Size.X * p.Size);
axisY = yd * (gfxInfo.Size.Y * p.Size);
}
else
{
Quaternion orientation = ParticleOrientation(em, p);
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
if (RetailParticleFacing.Faces(gfxInfo.DegradeMode)
&& toViewerLength > 1e-3f)
{
// Modes 3/4/5 — authored geometry spun around one local
// axis toward the viewer
// (Frame::rotate_around_axis_to_vector).
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
gfxInfo.DegradeMode,
orientation,
gfxInfo.AxisX,
gfxInfo.AxisY,
toViewer / toViewerLength,
cameraRight,
cameraUp);
Vector3 localNormal = Vector3.Cross(gfxInfo.AxisX, gfxInfo.AxisY);
Vector3 spunNormal = Vector3.Cross(xd, yd);
if (spunNormal.LengthSquared() > 1e-10f)
spunNormal = Vector3.Normalize(spunNormal);
Vector3 c = gfxInfo.CenterOffset;
pos += (xd * Vector3.Dot(c, gfxInfo.AxisX)
+ yd * Vector3.Dot(c, gfxInfo.AxisY)
+ spunNormal * Vector3.Dot(c, localNormal)) * p.Size;
axisX = xd * (gfxInfo.Size.X * p.Size);
axisY = yd * (gfxInfo.Size.Y * p.Size);
}
else
{
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
}
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
@ -576,7 +634,23 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind))
return kind;
uint? firstDegradeMode = null;
kind = RetailParticleGeometryClassifier.Classify(
ResolveFirstDegradeMode(gfxObjId));
_geometryKindByGfxObj[gfxObjId] = kind;
return kind;
}
/// <summary>
/// The sprite's FIRST degrade entry's mode — retail's facing selector
/// (<c>GfxObjDegradeInfo::get_degrade @0x0051E4B0</c> feeding
/// <c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>). Null when the
/// GfxObj has no degrade table.
/// </summary>
private uint? ResolveFirstDegradeMode(uint gfxObjId)
{
if (_firstDegradeModeByGfxObj.TryGetValue(gfxObjId, out uint? mode))
return mode;
try
{
if (_dats?.Get<GfxObj>(gfxObjId) is { } gfx
@ -584,7 +658,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
&& gfx.DIDDegrade != 0
&& _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade)
{
firstDegradeMode = degrade.Degrades[0].DegradeMode;
mode = degrade.Degrades[0].DegradeMode;
}
}
catch (Exception ex)
@ -596,9 +670,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
$"[particle-geometry] Failed to decode GfxObj 0x{gfxObjId:X8} degrade metadata: {ex.Message}");
}
kind = RetailParticleGeometryClassifier.Classify(firstDegradeMode);
_geometryKindByGfxObj[gfxObjId] = kind;
return kind;
_firstDegradeModeByGfxObj[gfxObjId] = mode;
return mode;
}
private void OnEmitterDied(int handle)
@ -672,7 +745,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
texture: AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned,
additive,
hasMaterial: surfaceId != 0,
surfaceId: surfaceId);
surfaceId: surfaceId,
degradeMode: ResolveFirstDegradeMode(gfxObjId) ?? 0u);
}
catch
{
@ -685,7 +759,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot texture,
bool additive,
bool hasMaterial,
uint surfaceId)
uint surfaceId,
uint degradeMode)
{
if (gfx.VertexArray.Vertices.Count == 0)
return ParticleGfxInfo.Billboard(
@ -779,24 +854,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
false,
additive,
hasMaterial,
surfaceId);
surfaceId,
degradeMode);
}
private bool IsPointSprite(GfxObj gfx)
{
if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null)
return false;
try
{
var degrade = _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade);
return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2;
}
catch
{
return false;
}
}
=> ResolveFirstDegradeMode(gfx.Id) == 2u;
private static float FallbackParticleExtent(float value)
=> value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f;
@ -886,10 +949,21 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_particleGfxInfoByEmitter.Clear();
_particleGfxInfoByGfxObj.Clear();
_geometryKindByGfxObj.Clear();
_firstDegradeModeByGfxObj.Clear();
_meshBlendBySurface.Clear();
_deferredAlpha.Clear();
}
/// <summary>
/// <paramref name="DegradeMode"/> is the sprite GfxObj's FIRST degrade
/// entry's mode — retail's facing selector
/// (<c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>, see
/// <see cref="AcDream.Core.Vfx.RetailParticleFacing"/>). Mode 2 sprites
/// take the <paramref name="IsBillboard"/> quad path (face viewer,
/// roll-free); modes 35 keep authored geometry but spin around one
/// local axis toward the viewer; every other mode draws authored.
/// Synthetic texture-only billboards carry mode 2 by construction.
/// </summary>
private readonly record struct ParticleGfxInfo(
AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot,
Vector2 Size,
@ -899,7 +973,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
bool IsBillboard,
bool Additive,
bool HasMaterial,
uint SurfaceId)
uint SurfaceId,
uint DegradeMode)
{
public static ParticleGfxInfo Default { get; } =
Billboard(
@ -926,6 +1001,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
true,
additive,
hasMaterial,
surfaceId);
surfaceId,
DegradeMode: 2u);
}
}

View file

@ -6,16 +6,47 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the optional DAT-archaeology sky-PES experiment. Named retail shows
/// GameSky does not consume SkyObject.PesObjectId, so production invokes this
/// owner only when the explicit startup diagnostic is enabled.
/// Production owner of retail's sky default-script playback — the aurora,
/// lightning-flash, and thunder effects authored on the sky carrier Setups.
///
/// <para><b>Retail mechanism</b> (full chain with decomp cites in
/// <c>docs/research/2026-08-23-sky-default-script-port.md</c>):
/// <c>GameSky::MakeObject @0x00506EE0</c> creates each visible sky object via
/// <c>CPhysicsObj::makeObject @0x00513970</c>; a Setup carrying a
/// <c>DefaultScript</c> marks the object <c>state |= 0x80000</c> and joins the
/// static-animating list, whose per-tick
/// <c>CPhysicsObj::animate_static_object @0x00513DF0</c> drives
/// <c>ScriptManager::UpdateScripts</c> and the object's ParticleManager. The
/// earlier April-2026 research correctly proved GameSky never reads the
/// <c>CelestialPosition.pes_id</c> column — the ids ride the Setup's own
/// DefaultScript instead (byte-equal in Dereth's Region DAT).</para>
///
/// <para><b>Slot identity / persistence</b> mirrors
/// <c>GameSky::CreateDeletePhysicsObjects @0x005073C0</c>: a slot keeps its
/// object (and therefore its running script and particle population) while
/// the slot's gfx id and properties word are unchanged; a mismatch — or the
/// object leaving its begin/end window, which retail expresses as the slot's
/// gfx id becoming INVALID — destroys and recreates it. Keys here are
/// (slot index, gfx id, properties) for exactly that contract.</para>
///
/// <para><b>Adaptations</b> (register row AD-112): the script anchors at the
/// camera (retail's sky-cell space is viewer-centered, so world-space camera
/// anchoring is the same geometry), and playback goes through
/// <see cref="PhysicsScriptRunner"/> synthetic owners instead of real
/// physics objects in a dedicated sky cell. Retail's
/// <c>LScape::weather_enabled</c> guard on <c>props &amp; 4</c> objects is
/// moot: acdream has no weather kill-switch, matching retail's default-on
/// state (see <see cref="SkyObjectData.IsWeather"/>). Sky scripts keep
/// running while the camera is indoors — retail's outside check gates only
/// <c>GameSky::Draw</c>, and our sky passes are likewise skipped by
/// <c>RenderSky</c> without stopping simulation.</para>
/// </summary>
internal sealed class SkyPesFrameController
{
private readonly record struct SkyPesKey(
int ObjectIndex,
uint PesObjectId,
bool PostScene);
uint GfxObjId,
uint Properties);
private readonly PhysicsScriptRunner _scripts;
private readonly ParticleHookSink _particles;
@ -23,108 +54,153 @@ internal sealed class SkyPesFrameController
private readonly EntityEffectController? _effects;
private readonly HashSet<SkyPesKey> _active = [];
private readonly HashSet<SkyPesKey> _missing = [];
private readonly HashSet<uint> _reportedScriptMismatches = [];
private readonly HashSet<SkyPesKey> _seenScratch = [];
private readonly List<SkyPesKey> _stopScratch = [];
private readonly Action<string>? _diagnostic;
public SkyPesFrameController(
PhysicsScriptRunner scripts,
ParticleHookSink particles,
EntityEffectPoseRegistry poses,
EntityEffectController? effects)
EntityEffectController? effects,
Action<string>? diagnostic = null)
{
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_effects = effects;
_diagnostic = diagnostic;
}
public void Update(
float dayFraction,
DayGroupData? dayGroup,
Vector3 cameraWorldPosition,
bool suppressSky)
Vector3 cameraWorldPosition)
{
var seen = new HashSet<SkyPesKey>();
if (!suppressSky && dayGroup is not null)
_seenScratch.Clear();
if (dayGroup is not null)
{
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
{
SkyObjectData skyObject = dayGroup.SkyObjects[index];
if (skyObject.PesObjectId == 0 || !skyObject.IsVisible(dayFraction))
if (ResolveScriptId(skyObject) == 0
|| !skyObject.IsVisible(dayFraction))
{
continue;
}
var key = new SkyPesKey(
_seenScratch.Add(new SkyPesKey(
index,
skyObject.PesObjectId,
skyObject.IsPostScene);
seen.Add(key);
uint ownerId = EntityId(key);
ParticleRenderPass renderPass = skyObject.IsPostScene
? ParticleRenderPass.SkyPostScene
: ParticleRenderPass.SkyPreScene;
_particles.SetEntityRenderPass(ownerId, renderPass);
Vector3 anchor = Anchor(skyObject, cameraWorldPosition);
Quaternion rotation = Rotation(skyObject, dayFraction);
_poses.Publish(
ownerId,
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(anchor),
Array.Empty<Matrix4x4>(),
cellId: 0u);
if (_active.Contains(key) || _missing.Contains(key))
continue;
_effects?.RegisterSyntheticOwner(ownerId);
if (_scripts.Play(skyObject.PesObjectId, ownerId, anchor))
{
_active.Add(key);
}
else
{
_missing.Add(key);
_effects?.UnregisterSyntheticOwner(ownerId);
_particles.ClearEntityRenderPass(ownerId);
_poses.Remove(ownerId);
}
skyObject.GfxObjId,
skyObject.Properties));
}
}
foreach (SkyPesKey key in _active.ToArray())
// Stop stale slots BEFORE starting replacements: EntityId is
// slot-derived, so a slot whose identity changed this frame must
// release its owner before the new identity claims it.
StopUnseen(_active, stopScripts: true);
StopUnseen(_missing, stopScripts: false);
if (dayGroup is null)
return;
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
{
if (seen.Contains(key))
SkyObjectData skyObject = dayGroup.SkyObjects[index];
uint scriptId = ResolveScriptId(skyObject);
if (scriptId == 0 || !skyObject.IsVisible(dayFraction))
continue;
var key = new SkyPesKey(
index,
skyObject.GfxObjId,
skyObject.Properties);
uint ownerId = EntityId(key);
_scripts.StopAllForEntity(ownerId);
_effects?.UnregisterSyntheticOwner(ownerId);
_particles.StopAllForEntity(ownerId, fadeOut: true);
_poses.Remove(ownerId);
_active.Remove(key);
ParticleRenderPass renderPass = skyObject.IsPostScene
? ParticleRenderPass.SkyPostScene
: ParticleRenderPass.SkyPreScene;
_particles.SetEntityRenderPass(ownerId, renderPass);
Quaternion rotation = Rotation(skyObject, dayFraction);
_poses.Publish(
ownerId,
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cameraWorldPosition),
Array.Empty<Matrix4x4>(),
cellId: 0u);
if (_active.Contains(key) || _missing.Contains(key))
continue;
_effects?.RegisterSyntheticOwner(ownerId);
if (_scripts.Play(scriptId, ownerId, cameraWorldPosition))
{
_active.Add(key);
}
else
{
_missing.Add(key);
_effects?.UnregisterSyntheticOwner(ownerId);
_particles.ClearEntityRenderPass(ownerId);
_poses.Remove(ownerId);
}
}
}
private void StopUnseen(HashSet<SkyPesKey> set, bool stopScripts)
{
_stopScratch.Clear();
foreach (SkyPesKey key in set)
{
if (!_seenScratch.Contains(key))
_stopScratch.Add(key);
}
foreach (SkyPesKey key in _missing.ToArray())
foreach (SkyPesKey key in _stopScratch)
{
if (!seen.Contains(key))
_missing.Remove(key);
if (stopScripts)
{
uint ownerId = EntityId(key);
_scripts.StopAllForEntity(ownerId);
_effects?.UnregisterSyntheticOwner(ownerId);
_particles.StopAllForEntity(ownerId, fadeOut: true);
_poses.Remove(ownerId);
}
set.Remove(key);
}
}
/// <summary>
/// The Setup's authored <c>DefaultScript</c> is the retail source; the
/// dead <c>pes_id</c> column is byte-equal in Dereth's DAT and serves
/// only as a one-time-logged cross-check for modded/foreign data.
/// </summary>
private uint ResolveScriptId(SkyObjectData skyObject)
{
uint scriptId = skyObject.DefaultScriptId;
if (scriptId != skyObject.PesObjectId
&& skyObject.GfxObjId != 0
&& _reportedScriptMismatches.Add(skyObject.GfxObjId))
{
_diagnostic?.Invoke(
$"[sky-pes] carrier 0x{skyObject.GfxObjId:X8}: Setup DefaultScript " +
$"0x{scriptId:X8} != SkyObject pes_id 0x{skyObject.PesObjectId:X8}; " +
"playing the DefaultScript (retail's source).");
}
return scriptId;
}
private static uint EntityId(SkyPesKey key)
{
uint postScene = key.PostScene ? 0x08000000u : 0u;
uint postScene = (key.Properties & 0x01u) != 0u ? 0x08000000u : 0u;
return 0xF0000000u
| postScene
| ((uint)key.ObjectIndex & 0x07FFFFFFu);
}
private static Vector3 Anchor(
SkyObjectData skyObject,
Vector3 cameraWorldPosition)
{
if (skyObject.IsWeather && (skyObject.Properties & 0x08u) == 0u)
return cameraWorldPosition + new Vector3(0f, 0f, -120f);
return cameraWorldPosition;
}
private static Quaternion Rotation(
SkyObjectData skyObject,
float dayFraction)

View file

@ -476,14 +476,14 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup)
{
if (_options.EnableSkyPesDebug)
{
_skyPes?.Update(
(float)_worldTime.DayFraction,
activeDayGroup,
camera.Position,
roots.CameraInsideCell);
}
// Retail's sky default scripts (aurora/lightning/thunder) run
// unconditionally — GameSky::UseTime/CreateDeletePhysicsObjects tick
// every frame regardless of the outside check, which gates only the
// draw. See docs/research/2026-08-23-sky-default-script-port.md.
_skyPes?.Update(
(float)_worldTime.DayFraction,
activeDayGroup,
camera.Position);
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);

View file

@ -45,7 +45,6 @@ public sealed record RuntimeOptions(
bool DumpMoveTruth,
bool DumpSky,
bool NoAudio,
bool EnableSkyPesDebug,
int HidePartIndex,
bool RetailCloseDegrades,
bool DumpSceneryZ,
@ -152,7 +151,6 @@ public sealed record RuntimeOptions(
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
DumpSky: IsExactlyOne(env("ACDREAM_DUMP_SKY")),
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
// Default-on: any value other than the literal string "0" enables
// retail close-detail degrades. Set ACDREAM_RETAIL_CLOSE_DEGRADES=0

View file

@ -0,0 +1,118 @@
using System.Numerics;
namespace AcDream.Core.Vfx;
/// <summary>
/// Retail's particle draw-frame facing law, ported verbatim from
/// <c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>:
///
/// <code>
/// draw = pos
/// switch (deg_mode):
/// 2: Frame::set_vector_heading(draw, viewer_heading) // face viewer, roll-free
/// 3/4/5: Frame::rotate_around_axis_to_vector(draw, X/Y/Z) // one free axis
/// else: authored orientation (mode 1, mode 0, out of range)
/// </code>
///
/// <c>viewer_heading</c> is the normalized part→viewer direction
/// (<c>CPhysicsPart::UpdateViewerDistance @0x0050E030</c>). The mode is the
/// FIRST degrade entry's <c>DegradeMode</c>
/// (<c>GfxObjDegradeInfo::get_degrade @0x0051E4B0</c>). Retail's
/// <c>Always2D</c> (mode != 1) affects only cell membership, not drawing —
/// the draw path is always the authored mesh with this facing applied.
/// Research: <c>docs/research/2026-08-23-sky-default-script-port.md</c>.
/// </summary>
public static class RetailParticleFacing
{
/// <summary>True when the mode orients the part toward the viewer at all
/// (retail's <c>deg_mode != 1 &amp;&amp; (deg_mode - 2) &lt;= 3</c>).</summary>
public static bool Faces(uint degradeMode)
=> degradeMode >= 2u && degradeMode <= 5u;
/// <summary>
/// Orients a particle quad per the retail law. Inputs are the particle's
/// authored orientation plus the local in-plane axes the renderer chose
/// for the sprite (unit vectors in sprite-local space); output is the
/// world-space direction pair for the quad's X/Y spans.
/// </summary>
/// <param name="degradeMode">First degrade entry's mode.</param>
/// <param name="orientation">The particle's authored world orientation.</param>
/// <param name="localAxisX">Sprite-local in-plane X (unit).</param>
/// <param name="localAxisY">Sprite-local in-plane Y (unit).</param>
/// <param name="toViewerUnit">Normalized particle→viewer direction.</param>
/// <param name="fallbackRight">Basis used when the facing construction is
/// degenerate (viewer straight along world up) — the camera right.</param>
/// <param name="fallbackUp">Camera up, same degenerate fallback.</param>
public static (Vector3 XDir, Vector3 YDir) OrientQuad(
uint degradeMode,
Quaternion orientation,
Vector3 localAxisX,
Vector3 localAxisY,
Vector3 toViewerUnit,
Vector3 fallbackRight,
Vector3 fallbackUp)
{
if (degradeMode == 2u)
return FaceViewerRollFree(toViewerUnit, fallbackRight, fallbackUp);
Vector3 worldX = Vector3.Transform(localAxisX, orientation);
Vector3 worldY = Vector3.Transform(localAxisY, orientation);
if (degradeMode < 3u || degradeMode > 5u)
return (worldX, worldY);
// Modes 3/4/5 spin the authored frame around its own local X/Y/Z so
// the sprite's face normal points at the viewer as far as the
// constraint allows (Frame::rotate_around_axis_to_vector).
Vector3 localAxis = degradeMode switch
{
3u => Vector3.UnitX,
4u => Vector3.UnitY,
_ => Vector3.UnitZ,
};
Vector3 axis = Vector3.Transform(localAxis, orientation);
Vector3 normal = Vector3.Cross(worldX, worldY);
if (normal.LengthSquared() < 1e-10f)
return (worldX, worldY);
normal = Vector3.Normalize(normal);
Vector3 targetInPlane = toViewerUnit - axis * Vector3.Dot(toViewerUnit, axis);
Vector3 normalInPlane = normal - axis * Vector3.Dot(normal, axis);
if (targetInPlane.LengthSquared() < 1e-8f
|| normalInPlane.LengthSquared() < 1e-8f)
{
return (worldX, worldY);
}
targetInPlane = Vector3.Normalize(targetInPlane);
normalInPlane = Vector3.Normalize(normalInPlane);
float cos = Math.Clamp(Vector3.Dot(normalInPlane, targetInPlane), -1f, 1f);
float sin = Vector3.Dot(Vector3.Cross(normalInPlane, targetInPlane), axis);
float angle = MathF.Atan2(sin, cos);
var spin = Quaternion.CreateFromAxisAngle(axis, angle);
return (Vector3.Transform(worldX, spin), Vector3.Transform(worldY, spin));
}
/// <summary>
/// Mode 2 — <c>Frame::set_vector_heading</c>: the quad's face normal
/// points at the viewer with zero roll against world up (+Z). The quad's
/// X span stays horizontal; its Y span becomes the in-plane up.
/// </summary>
private static (Vector3 XDir, Vector3 YDir) FaceViewerRollFree(
Vector3 toViewerUnit,
Vector3 fallbackRight,
Vector3 fallbackUp)
{
Vector3 right = Vector3.Cross(toViewerUnit, Vector3.UnitZ);
if (right.LengthSquared() < 1e-8f)
{
// Viewer straight above/below the part: heading is undefined —
// hold the camera plane, which retail's next frame resolves the
// same way once the direction tilts.
return (fallbackRight, fallbackUp);
}
right = Vector3.Normalize(right);
Vector3 up = Vector3.Cross(right, toViewerUnit);
return (right, up);
}
}

View file

@ -38,6 +38,22 @@ public sealed class SkyObjectData
public uint PesObjectId;
public uint Properties;
/// <summary>
/// The carrier Setup's own <c>DefaultScript</c> PES id (zero when the
/// gfx id is not a Setup or the Setup has none). This — not
/// <see cref="PesObjectId"/> — is what retail plays: a Setup with a
/// default script marks its physics object <c>state |= 0x80000</c>
/// (<c>CPhysicsObj::makeObject @0x00513970</c> →
/// <c>InitPartArrayObject</c>) and the static-animating tick
/// (<c>CPhysicsObj::animate_static_object @0x00513DF0</c>) drives
/// <c>ScriptManager::UpdateScripts</c> + the object's ParticleManager.
/// In Dereth's Region DAT the two ids are byte-equal for every sky
/// carrier (verified 2026-08-23); the dead <c>pes_id</c> column stays
/// parsed as a cross-check only. Full chain:
/// <c>docs/research/2026-08-23-sky-default-script-port.md</c>.
/// </summary>
public uint DefaultScriptId;
/// <summary>
/// Source GfxObj sort centre. Celestial billboards are authored at their
/// apparent direction from the camera, so transforming and normalizing
@ -525,11 +541,40 @@ public static class SkyDescLoader
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u,
Properties = s.Properties,
DefaultScriptId = ResolveSetupDefaultScript(
s.DefaultGfxObjectId?.DataId ?? 0u,
dats),
AuthoredSortCenter = ResolveSortCenter(
s.DefaultGfxObjectId?.DataId ?? 0u,
dats),
};
/// <summary>
/// Fetches a sky carrier Setup's <c>DefaultScript</c> id (the retail
/// sky-PES source — see <see cref="SkyObjectData.DefaultScriptId"/>).
/// Non-Setup ids (the dome, star layers, cloud sheets are raw GfxObjs)
/// and missing DATs resolve to zero.
/// </summary>
private static uint ResolveSetupDefaultScript(
uint gfxObjId,
IDatObjectSource? dats)
{
if (dats is null || (gfxObjId & 0xFF000000u) != 0x02000000u)
return 0u;
try
{
return dats.TryGet<Setup>(gfxObjId, out var setup) && setup is not null
? setup.DefaultScript.DataId
: 0u;
}
catch
{
// Enhancement metadata cannot make authoritative sky loading fail.
return 0u;
}
}
private static DatSkyKeyframeData ConvertTimeOfDay(
SkyTimeOfDay s,
IDatObjectSource? dats)