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

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