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>
211 lines
8.1 KiB
C#
211 lines
8.1 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.Core.Vfx;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// 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 & 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 GfxObjId,
|
|
uint Properties);
|
|
|
|
private readonly PhysicsScriptRunner _scripts;
|
|
private readonly ParticleHookSink _particles;
|
|
private readonly EntityEffectPoseRegistry _poses;
|
|
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,
|
|
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)
|
|
{
|
|
_seenScratch.Clear();
|
|
if (dayGroup is not null)
|
|
{
|
|
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
|
|
{
|
|
SkyObjectData skyObject = dayGroup.SkyObjects[index];
|
|
if (ResolveScriptId(skyObject) == 0
|
|
|| !skyObject.IsVisible(dayFraction))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_seenScratch.Add(new SkyPesKey(
|
|
index,
|
|
skyObject.GfxObjId,
|
|
skyObject.Properties));
|
|
}
|
|
}
|
|
|
|
// 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++)
|
|
{
|
|
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);
|
|
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 _stopScratch)
|
|
{
|
|
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.Properties & 0x01u) != 0u ? 0x08000000u : 0u;
|
|
return 0xF0000000u
|
|
| postScene
|
|
| ((uint)key.ObjectIndex & 0x07FFFFFFu);
|
|
}
|
|
|
|
private static Quaternion Rotation(
|
|
SkyObjectData skyObject,
|
|
float dayFraction)
|
|
{
|
|
float radians = skyObject.CurrentAngle(dayFraction) * (MathF.PI / 180f);
|
|
return Quaternion.CreateFromAxisAngle(Vector3.UnitY, -radians);
|
|
}
|
|
}
|