using AcDream.App.Physics; using AcDream.Core.Physics; namespace AcDream.App.World; /// /// Applies the presentation/collision side effects of accepted retail /// PhysicsState transitions after the canonical live owner is ready. /// Logical ownership stays in ; Hidden never /// unregisters scripts, particles, lights, meshes, or the live record. /// /// /// Ports CPhysicsObj::set_state (0x00514DD0), /// set_nodraw (0x0050FCA0), and set_hidden /// (0x00514C60). Typed script ids are retail /// PS_UnHide=0x75 and PS_Hidden=0x76 from acclient.h. /// public sealed class LiveEntityPresentationController : IDisposable { public const uint UnHideScriptType = 0x75u; public const uint HiddenScriptType = 0x76u; private readonly LiveEntityRuntime _liveEntities; private readonly ShadowObjectRegistry _shadows; private readonly Func _playTyped; private readonly Action _setDirectChildrenNoDraw; private readonly Action _clearInvalidTarget; private readonly Func<(int X, int Y)> _liveCenter; private readonly Action? _onShadowRestored; private readonly Action _handlePartArrayEnterWorld; private readonly Dictionary _readyGenerationByGuid = new(); private readonly Dictionary _suspendedShadowGenerationByGuid = new(); private readonly Dictionary _activePlacementGenerationByGuid = new(); private readonly HashSet _drainingRecords = new(); private bool _disposed; public LiveEntityPresentationController( LiveEntityRuntime liveEntities, ShadowObjectRegistry shadows, Func playTyped, Action? setDirectChildrenNoDraw = null, Action? clearInvalidTarget = null, Func<(int X, int Y)>? liveCenter = null, Action? onShadowRestored = null, Action? handlePartArrayEnterWorld = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped)); _setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { }); _clearInvalidTarget = clearInvalidTarget ?? (_ => { }); _liveCenter = liveCenter ?? (() => (0, 0)); _onShadowRestored = onShadowRestored; _handlePartArrayEnterWorld = handlePartArrayEnterWorld ?? (_ => { }); _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } /// /// Opens the state-side-effect barrier after render/effect owners have /// registered, then drains constructor and pre-materialization transitions /// exactly once in accepted order. /// public bool OnLiveEntityReady(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.WorldEntity is null || !record.ResourcesRegistered) { return false; } _readyGenerationByGuid[serverGuid] = record.Generation; if (!ApplyPendingTransitions(record) || record.WorldEntity is not { } entity || !IsCurrent(record, entity)) { return false; } // An object can materialize into a pending landblock before collision // registration and before this ready barrier opens. Its initial false // visibility edge therefore had nothing to suspend. Reconcile here, // after every create-time owner exists, so the retained registration // cannot remain active offscreen and hydration has a restore marker. SuspendOrdinaryShadowOutsideProjection(record, entity); return IsCurrent(record, entity); } /// Drains a newly accepted SetState when this incarnation is ready. public bool OnStateAccepted(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation) || generation != record.Generation) { return false; } return ApplyPendingTransitions(record); } /// /// Retains ownership of a collision shadow that another retail transition /// suspended while this incarnation's spatial projection is unavailable. /// Hidden/UnHide and teleport rollback intentionally converge on this one /// generation-scoped restoration marker. /// public bool DeferShadowRestore(uint serverGuid, ushort generation) => CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true); /// /// Completes active placement ownership after its final frame/cell is /// known, either returning a stable deferred restore to presentation or /// clearing the suspension for an immediate controller-owned restore. /// public bool CompleteAuthoritativePlacement( uint serverGuid, ushort generation, bool deferShadowRestore) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.Generation != generation || record.WorldEntity is null) { return false; } if (_activePlacementGenerationByGuid.TryGetValue( serverGuid, out ushort activeGeneration) && activeGeneration == generation) { _activePlacementGenerationByGuid.Remove(serverGuid); } if (deferShadowRestore) _suspendedShadowGenerationByGuid[serverGuid] = generation; else if (_suspendedShadowGenerationByGuid.TryGetValue( serverGuid, out ushort deferredGeneration) && deferredGeneration == generation) _suspendedShadowGenerationByGuid.Remove(serverGuid); return true; } /// /// Transfers a deferred restore to a newer authoritative placement before /// that placement rebuckets and can become visible. The placement hands /// ownership back after it reaches a stable Hidden destination/rollback; /// this prevents an intervening UnHide from restoring an unresolved pose. /// public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.Generation != generation) { return false; } _activePlacementGenerationByGuid[serverGuid] = generation; if (_suspendedShadowGenerationByGuid.TryGetValue( serverGuid, out ushort deferredGeneration) && deferredGeneration == generation) { _suspendedShadowGenerationByGuid.Remove(serverGuid); } return true; } internal bool HasDeferredShadowRestore(uint serverGuid) => _suspendedShadowGenerationByGuid.ContainsKey(serverGuid); internal bool HasActivePlacement(uint serverGuid) => _activePlacementGenerationByGuid.ContainsKey(serverGuid); internal int ReadyOwnerCount => _readyGenerationByGuid.Count; internal int DeferredShadowRestoreCount => _suspendedShadowGenerationByGuid.Count; internal int ActivePlacementCount => _activePlacementGenerationByGuid.Count; public void Forget(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation) && generation == record.Generation) { _readyGenerationByGuid.Remove(record.ServerGuid); } if (_suspendedShadowGenerationByGuid.TryGetValue( record.ServerGuid, out ushort suspendedGeneration) && suspendedGeneration == record.Generation) { _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); } if (_activePlacementGenerationByGuid.TryGetValue( record.ServerGuid, out ushort activeGeneration) && activeGeneration == record.Generation) { _activePlacementGenerationByGuid.Remove(record.ServerGuid); } _drainingRecords.Remove(record); } public void Clear() { _readyGenerationByGuid.Clear(); _suspendedShadowGenerationByGuid.Clear(); _activePlacementGenerationByGuid.Clear(); _drainingRecords.Clear(); } public void Dispose() { if (_disposed) return; _disposed = true; _liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; Clear(); } private bool ApplyPendingTransitions(LiveEntityRecord record) { // State callbacks can synchronously accept another SetState. Retail's // update thread completes the current transition before the next FIFO // entry; a recursive drain would interleave Visible halfway through // Hidden and then let the older side effects win last. if (!_drainingRecords.Add(record)) return IsCurrent(record, record.WorldEntity); try { while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition)) { if (record.WorldEntity is not { } entity || !IsCurrent(record, entity)) { return false; } // set_state writes the final state before any PartArray/cell side // effect. Retained shadow registrations must see those same bits // even while their cell rows are suspended. _shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState); switch (transition.HiddenTransition) { case RetailHiddenTransition.BecameHidden: _playTyped(entity.Id, HiddenScriptType, 1f); if (!IsCurrent(record, entity)) return false; _setDirectChildrenNoDraw(record.ServerGuid, true); if (!IsCurrent(record, entity)) return false; _shadows.Suspend(entity.Id); if (!IsPlacementActive(record)) _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; // Retail CPhysicsObj::set_hidden @ 0x00514C60 calls // CPartArray::HandleEnterWorld after hiding the object // from its cell. Despite the name, this is the motion // timeline boundary: it strips link animations and // aborts every pending completion through // MotionTableManager::HandleEnterWorld @ 0x0051BDD0. _handlePartArrayEnterWorld(entity.Id); if (!IsCurrent(record, entity)) return false; _clearInvalidTarget(record.ServerGuid); if (!IsCurrent(record, entity)) return false; break; case RetailHiddenTransition.BecameVisible: _playTyped(entity.Id, UnHideScriptType, 1f); if (!IsCurrent(record, entity)) return false; _setDirectChildrenNoDraw(record.ServerGuid, false); if (!IsCurrent(record, entity)) return false; // Retail invokes the same PartArray boundary before // CObjCell::unhide_object restores cell visibility. _handlePartArrayEnterWorld(entity.Id); if (!IsCurrent(record, entity)) return false; bool restored = !IsPlacementActive(record) && RestoreShadow(record, entity); if (!IsCurrent(record, entity)) return false; if (restored) _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); break; } } return IsCurrent(record, record.WorldEntity); } finally { _drainingRecords.Remove(record); } } private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity) { if (!record.IsSpatiallyProjected || !record.IsSpatiallyVisible || record.FullCellId == 0) { return false; } (int centerX, int centerY) = _liveCenter(); ShadowPositionSynchronizer.Sync( _shadows, entity.Id, entity.Position, entity.Rotation, record.FullCellId, centerX, centerY); _onShadowRestored?.Invoke(record.ServerGuid); return true; } private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { if (record.WorldEntity is not { } entity || !IsCurrent(record, entity)) { return; } // exit_world removes an ordinary object's collision rows on the same // projection edge that suspends its object clock. Keep the retained // registration so a stationary object can be restored immediately on // hydration; it may have no later movement quantum to repair itself. // Projectiles and active authoritative placements own their matching // suspend/restore transaction in their dedicated controllers. if (!visible) { SuspendOrdinaryShadowOutsideProjection(record, entity); return; } if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 || IsPlacementActive(record) || !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration) || readyGeneration != record.Generation || !_suspendedShadowGenerationByGuid.TryGetValue( record.ServerGuid, out ushort suspendedGeneration) || suspendedGeneration != record.Generation) { return; } bool restored = RestoreShadow(record, entity); if (!IsCurrent(record, entity)) return; if (restored) _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); } private void SuspendOrdinaryShadowOutsideProjection( LiveEntityRecord record, AcDream.Core.World.WorldEntity entity) { if (record.IsSpatiallyVisible || record.ProjectileRuntime is not null || IsPlacementActive(record) || !_readyGenerationByGuid.TryGetValue( record.ServerGuid, out ushort readyGeneration) || readyGeneration != record.Generation || !_shadows.Suspend(entity.Id)) { return; } _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; } private bool IsCurrent( LiveEntityRecord record, AcDream.Core.World.WorldEntity? entity) => entity is not null && _liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) && ReferenceEquals(current, record) && ReferenceEquals(current.WorldEntity, entity); private bool IsPlacementActive(LiveEntityRecord record) => _activePlacementGenerationByGuid.TryGetValue( record.ServerGuid, out ushort generation) && generation == record.Generation; }