using System.Numerics; using AcDream.App.World; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Vfx; using AcDream.Core.World; using DatReaderWriter.Types; namespace AcDream.App.Rendering.Vfx; /// /// Update-thread owner of live effect profiles, mixed pre-materialization /// F754/F755 delivery, typed-table resolution, and effect-producing animation /// hooks. Server GUIDs are translated here; downstream sinks see local IDs. /// /// /// Network pending delivery follows retail /// SmartBox::HandlePlayScriptID (0x00452020) and /// SmartBox::HandlePlayScriptType (0x00452070): /// queue only while the object is absent; an existing cell-less object no-ops. /// Typed/default playback ports CPhysicsObj::play_script /// (0x00513260) and both play_default_script overloads /// (0x005132B0, 0x00513300). /// public sealed class EntityEffectController : IAnimationHookSink { private readonly LiveEntityRuntime _liveEntities; private readonly PhysicsScriptRunner _runner; private readonly PhysicsScriptTableResolver _tables; private readonly Func _childAtPart; private readonly Func _parentOfAttachedChild; private readonly Action _ownerUnregistered; private readonly Action _ownerSoundTableChanged; private readonly Dictionary _profilesByLocalId = new(); private readonly HashSet _readyServerGuids = new(); private readonly Dictionary> _pendingByServerGuid = new(); private readonly Dictionary _staticOwners = new(); private readonly HashSet _syntheticOwners = new(); private Action? _diagnosticSink = Console.WriteLine; public EntityEffectController( LiveEntityRuntime liveEntities, PhysicsScriptRunner runner, PhysicsScriptTableResolver tables, Func? childAtPart = null, Func? parentOfAttachedChild = null, Action? ownerUnregistered = null, Action? ownerSoundTableChanged = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _runner = runner ?? throw new ArgumentNullException(nameof(runner)); _tables = tables ?? throw new ArgumentNullException(nameof(tables)); _childAtPart = childAtPart ?? ((_, _) => null); _parentOfAttachedChild = parentOfAttachedChild ?? (_ => null); _ownerUnregistered = ownerUnregistered ?? (_ => { }); _ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { }); _runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message); } public Action? DiagnosticSink { get => _diagnosticSink; set => _diagnosticSink = value; } public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count); public int ReadyOwnerCount => _profilesByLocalId.Count; public void HandleDirect(PlayPhysicsScript message) { if (message.Guid == 0) return; if (TryGetReadyLocalId(message.Guid, out uint localId)) { RefreshLiveAnchor(message.Guid, localId); if (CanStartOwner(localId)) PlayDirect(localId, message.ScriptDid); return; } Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid)); } public void HandleTyped(PlayPhysicsScriptType message) { if (message.Guid == 0) return; if (TryGetReadyLocalId(message.Guid, out uint localId)) { RefreshLiveAnchor(message.Guid, localId); if (CanStartOwner(localId)) PlayTyped(localId, message.RawScriptType, message.Intensity); return; } Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity)); } /// /// Marks a live owner ready only after its projection, resource owners, and /// effect profile have all registered. Pending packets replay once in their /// original mixed F754/F755 order. /// public bool OnLiveEntityReady(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.WorldEntity is not { } entity || !record.ResourcesRegistered || record.EffectProfile is not EntityEffectProfile profile) { return false; } _readyServerGuids.Add(serverGuid); _profilesByLocalId[entity.Id] = profile; _runner.SetOwnerAnchor(entity.Id, entity.Position); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); TryReplayPending(serverGuid, entity.Id); return true; } /// /// Re-publishes network-owned profile resources after a same-generation /// PhysicsDesc replacement. Retail CPhysicsObj::set_description /// (0x00514F40) releases and reinstalls stable_id on every /// description application, including present-zero clearing. /// public bool OnLiveEntityDescriptionChanged(uint serverGuid) { if (!TryGetReadyLocalId(serverGuid, out uint localId) || !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.EffectProfile is not EntityEffectProfile profile) { return false; } _profilesByLocalId[localId] = profile; _ownerSoundTableChanged(localId, profile.CurrentSoundTableDid); return true; } public void OnDatStaticEntityReady( uint ownerLocalId, WorldEntity entity, EntityEffectProfile profile) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(profile); if (ownerLocalId == 0) return; _staticOwners[ownerLocalId] = entity; _profilesByLocalId[ownerLocalId] = profile; _runner.SetOwnerAnchor(ownerLocalId, entity.Position); _ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid); } public void OnDatStaticEntityRemoved(uint localId) { if (!_staticOwners.Remove(localId)) return; _profilesByLocalId.Remove(localId); _runner.StopAllForEntity(localId); _ownerUnregistered(localId); } public void RegisterSyntheticOwner(uint ownerLocalId) { if (ownerLocalId != 0) _syntheticOwners.Add(ownerLocalId); } public void UnregisterSyntheticOwner(uint ownerLocalId) => _syntheticOwners.Remove(ownerLocalId); public void OnLiveEntityUnregistered(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); _pendingByServerGuid.Remove(record.ServerGuid); _readyServerGuids.Remove(record.ServerGuid); if (record.LocalEntityId is not { } localId) return; _profilesByLocalId.Remove(localId); _runner.StopAllForEntity(localId); _ownerUnregistered(localId); } /// Clears a pending owner that never reached CreateObject. public void ForgetUnknownOwner(uint serverGuid) => _pendingByServerGuid.Remove(serverGuid); public void ClearNetworkState() { foreach (uint serverGuid in _readyServerGuids.ToArray()) { if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId)) { _profilesByLocalId.Remove(localId); _runner.StopAllForEntity(localId); _ownerUnregistered(localId); } } _readyServerGuids.Clear(); _pendingByServerGuid.Clear(); } /// Refreshes every live root anchor after animation/movement. public void RefreshLiveOwnerAnchors() { foreach (uint serverGuid in _readyServerGuids.ToArray()) { if (!TryGetReadyLocalId(serverGuid, out uint localId)) continue; RefreshLiveAnchor(serverGuid, localId); TryReplayPending(serverGuid, localId); } } public bool PlayDirect(uint ownerLocalId, uint scriptDid) { if (!CanStartOwner(ownerLocalId)) return false; return _runner.PlayDirect(ownerLocalId, scriptDid); } public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity) { if (!CanStartOwner(ownerLocalId) || !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile) || profile.CurrentPhysicsScriptTableDid is not { } tableDid) { DiagnosticSink?.Invoke( $"No PhysicsScriptTable for owner 0x{ownerLocalId:X8}, type 0x{rawScriptType:X8}."); return false; } uint? scriptDid = _tables.Resolve( tableDid, rawScriptType, intensity, out Exception? loadFailure); if (scriptDid is not { } resolved) { string detail = loadFailure is null ? string.Empty : $" Load failed: {loadFailure.GetType().Name}: {loadFailure.Message}"; DiagnosticSink?.Invoke( $"No typed PhysicsScript for owner 0x{ownerLocalId:X8}, table 0x{tableDid:X8}, " + $"type 0x{rawScriptType:X8}, intensity {intensity:R}.{detail}"); return false; } return _runner.PlayDirect(ownerLocalId, resolved); } public bool PlayDefault(uint ownerLocalId) { if (!CanStartOwner(ownerLocalId) || !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile)) return false; return PlayTyped( ownerLocalId, profile.RawDefaultScriptType, profile.DefaultScriptIntensity); } public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { _runner.SetOwnerAnchor(entityId, entityWorldPosition); switch (hook) { case CallPESHook call: if (CanStartOwner(entityId)) _runner.ScheduleCallPes(entityId, call.PES, call.Pause); break; case DefaultScriptHook: PlayDefault(entityId); break; case DefaultScriptPartHook part: if (_childAtPart(entityId, part.PartIndex) is { } childLocalId) PlayDefault(childLocalId); break; } } /// /// Retail CPhysicsObj::update_object (0x00515D40) advances /// root scripts only with a cell and while not Frozen. Parented objects /// advance through their eligible parent's UpdateChild path instead of /// their own cell-less root update. /// public bool CanAdvanceOwner(uint ownerLocalId) { if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId)) return true; if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId) return CanAdvanceLiveRoot(parentLocalId); return CanAdvanceLiveRoot(ownerLocalId); } private bool CanStartOwner(uint ownerLocalId) { if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId)) return true; if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId) return IsLiveRootInCell(parentLocalId); return IsLiveRootInCell(ownerLocalId); } private bool CanAdvanceLiveRoot(uint ownerLocalId) { if (!TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record)) return false; return IsLiveRootInCell(record) && (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0; } private bool IsLiveRootInCell(uint ownerLocalId) => TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record) && IsLiveRootInCell(record); private static bool IsLiveRootInCell(LiveEntityRecord record) => record.ResourcesRegistered && record.IsSpatiallyProjected && record.IsSpatiallyVisible && record.FullCellId != 0; private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record) { if (_liveEntities.TryGetServerGuid(ownerLocalId, out uint serverGuid) && _liveEntities.TryGetRecord(serverGuid, out record!)) { return true; } record = null!; return false; } private bool TryGetReadyLocalId(uint serverGuid, out uint localId) { if (_readyServerGuids.Contains(serverGuid) && _liveEntities.TryGetLocalEntityId(serverGuid, out localId) && _profilesByLocalId.ContainsKey(localId)) { return true; } localId = 0; return false; } private void RefreshLiveAnchor(uint serverGuid, uint localId) { if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) && record.WorldEntity is { } entity && entity.Id == localId) { _runner.SetOwnerAnchor(localId, entity.Position); } } private void Enqueue(uint serverGuid, PendingEffect effect) { if (!_pendingByServerGuid.TryGetValue(serverGuid, out Queue? queue)) { queue = new Queue(); _pendingByServerGuid.Add(serverGuid, queue); } queue.Enqueue(effect); } private void TryReplayPending(uint serverGuid, uint localId) { if (!CanStartOwner(localId) || !_pendingByServerGuid.Remove(serverGuid, out Queue? pending)) { return; } while (pending.Count > 0) Execute(localId, pending.Dequeue()); } private void Execute(uint localId, PendingEffect effect) { if (effect.Kind is PendingEffectKind.Direct) PlayDirect(localId, effect.ScriptDid); else PlayTyped(localId, effect.RawScriptType, effect.Intensity); } private enum PendingEffectKind { Direct, Typed, } private readonly record struct PendingEffect( PendingEffectKind Kind, uint ScriptDid, uint RawScriptType, float Intensity) { public static PendingEffect Direct(uint scriptDid) => new(PendingEffectKind.Direct, scriptDid, 0u, 0f); public static PendingEffect Typed(uint rawScriptType, float intensity) => new(PendingEffectKind.Typed, 0u, rawScriptType, intensity); } }