using AcDream.App.World; using AcDream.Core.Lighting; using AcDream.Core.Physics; using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; namespace AcDream.App.Rendering.Vfx; /// /// Projects Setup lights for live top-level and attached entities, while the /// shared pose registry supplies their current root on each update frame. /// /// /// Retail CPartArray::SetFrame (0x00519310) calls /// LIGHTLIST::set_frame (0x00517C60) whenever the owning physics /// object's frame changes. Logical ownership remains in /// ; leaving the world removes only this /// cell-scoped presentation and re-entry registers it again. /// public sealed class LiveEntityLightController : IDisposable { private readonly LiveEntityRuntime _liveEntities; private readonly EntityEffectPoseRegistry _poses; private readonly LightingHookSink _lighting; private readonly Func _loadSetup; private readonly HashSet _trackedOwners = []; private readonly HashSet _presentOwners = []; public LiveEntityLightController( LiveEntityRuntime liveEntities, EntityEffectPoseRegistry poses, LightingHookSink lighting, Func loadSetup) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); _loadSetup = loadSetup ?? throw new ArgumentNullException(nameof(loadSetup)); _lighting.OwnerLightingChanged += OnOwnerLightingChanged; _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } public bool Register(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.WorldEntity is not { } entity || !record.IsSpatiallyProjected || !record.IsSpatiallyVisible) { return false; } RuntimeEntityKey key = RequireProjectionKey(record); _trackedOwners.Add(key); _presentOwners.Add(key); _lighting.UnregisterOwner(entity.Id, forgetState: false); _lighting.InitializeOwnerLighting( entity.Id, (record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0); if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) != 0x02000000u) return false; if (record.ProjectionKind is LiveEntityProjectionKind.World) { if (!_poses.UpdateRoot(entity)) _poses.PublishMeshRefs(entity); } Setup? setup = _loadSetup(entity.SourceGfxObjOrSetupId); if (setup is null || setup.Lights.Count == 0 || !_lighting.IsOwnerLightingEnabled(entity.Id)) return false; bool isDynamic = (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0; IReadOnlyList loaded = LightInfoLoader.Load( setup, entity.Id, entity.Position, entity.Rotation, isDynamic, entity.ParentCellId ?? 0u, tracksOwnerPose: true); for (int i = 0; i < loaded.Count; i++) _lighting.RegisterOwnedLight(loaded[i]); return loaded.Count > 0; } /// Withdraw cell-scoped presentation but retain logical light state. public void Unregister(uint ownerLocalId) { if (_liveEntities.TryGetRecordByLocalEntityId( ownerLocalId, out LiveEntityRecord record) && record.ProjectionKey is { } key) { _presentOwners.Remove(key); } _lighting.UnregisterOwner(ownerLocalId, forgetState: false); } /// Apply a fresh authoritative PhysicsState Lighting transition. public void OnStateChanged(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.WorldEntity is not { } entity) { return; } _lighting.SetOwnerLighting( entity.Id, (record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0); } /// End logical ownership after leave-world presentation teardown. public void Forget(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); if (record.ProjectionKey is not { } key) return; _presentOwners.Remove(key); _trackedOwners.Remove(key); _lighting.UnregisterOwner(key.LocalEntityId); } public void Refresh() => _lighting.RefreshAttachedLights(); internal int TrackedOwnerCount => _trackedOwners.Count; internal int PresentedOwnerCount => _presentOwners.Count; /// /// Attached projection visibility can become true before its holding-part /// composition is published. The attachment owner calls this barrier only /// after the composed child root is current. /// public void OnAttachedPoseReady(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.ProjectionKind is not LiveEntityProjectionKind.Attached || record.WorldEntity is not { } entity || record.ProjectionKey is not { } key || _presentOwners.Contains(key)) { return; } Register(serverGuid); } private void OnOwnerLightingChanged(uint ownerLocalId, bool enabled) { if (!_liveEntities.TryGetRecordByLocalEntityId( ownerLocalId, out LiveEntityRecord record) || record.ProjectionKey is not { } key || !_presentOwners.Contains(key)) { return; } if (!enabled) { _lighting.UnregisterOwner(ownerLocalId, forgetState: false); return; } Register(record.ServerGuid); } public void Dispose() { _lighting.OwnerLightingChanged -= OnOwnerLightingChanged; _liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; foreach (RuntimeEntityKey key in _trackedOwners) _lighting.UnregisterOwner(key.LocalEntityId); _trackedOwners.Clear(); _presentOwners.Clear(); } private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { if (record.WorldEntity is not { } entity) return; if (record.ProjectionKind is LiveEntityProjectionKind.Attached) { // The true edge precedes attachment pose publication. False is // safe and must immediately withdraw the cell-scoped light. if (!visible) Unregister(entity.Id); return; } if (visible) Register(record.ServerGuid); else Unregister(entity.Id); } private static RuntimeEntityKey RequireProjectionKey( LiveEntityRecord record) => record.ProjectionKey ?? throw new InvalidOperationException( $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + "has no exact projection key."); }