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

@ -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)