using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.Core.Vfx; using AcDream.Core.World; namespace AcDream.App.Rendering; /// /// Production owner of retail's sky default-script playback — the aurora, /// lightning-flash, and thunder effects authored on the sky carrier Setups. /// /// Retail mechanism (full chain with decomp cites in /// docs/research/2026-08-23-sky-default-script-port.md): /// GameSky::MakeObject @0x00506EE0 creates each visible sky object via /// CPhysicsObj::makeObject @0x00513970; a Setup carrying a /// DefaultScript marks the object state |= 0x80000 and joins the /// static-animating list, whose per-tick /// CPhysicsObj::animate_static_object @0x00513DF0 drives /// ScriptManager::UpdateScripts and the object's ParticleManager. The /// earlier April-2026 research correctly proved GameSky never reads the /// CelestialPosition.pes_id column — the ids ride the Setup's own /// DefaultScript instead (byte-equal in Dereth's Region DAT). /// /// Slot identity / persistence mirrors /// GameSky::CreateDeletePhysicsObjects @0x005073C0: 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. /// /// Adaptations (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 /// synthetic owners instead of real /// physics objects in a dedicated sky cell. Retail's /// LScape::weather_enabled guard on props & 4 objects is /// moot: acdream has no weather kill-switch, matching retail's default-on /// state (see ). Sky scripts keep /// running while the camera is indoors — retail's outside check gates only /// GameSky::Draw, and our sky passes are likewise skipped by /// RenderSky without stopping simulation. /// 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 _active = []; private readonly HashSet _missing = []; private readonly HashSet _reportedScriptMismatches = []; private readonly HashSet _seenScratch = []; private readonly List _stopScratch = []; private readonly Action? _diagnostic; public SkyPesFrameController( PhysicsScriptRunner scripts, ParticleHookSink particles, EntityEffectPoseRegistry poses, EntityEffectController? effects, Action? 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(), 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 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); } } /// /// The Setup's authored DefaultScript is the retail source; the /// dead pes_id column is byte-equal in Dereth's DAT and serves /// only as a one-time-logged cross-check for modded/foreign data. /// 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); } }