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);
}
}