using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.App.Rendering; /// /// Ports retail's separate CPhysics::static_animating_objects workset /// (CPhysics::UseTime 0x00509950 and /// CPhysicsObj::animate_static_object 0x00513DF0). Membership is /// Setup/state-driven: any physics-Static object with Setup.DefaultAnimation /// enters, whether DAT-hydrated or server-created, and leaves with its logical /// resource lifetime. Setup default installation itself is unconditional and /// belongs to PartArray construction; this class owns only the Static workset. /// internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFrameSource { private const double FrameEpsilon = 0.000199999995; private const float MaximumElapsed = 2f; private sealed class Owner { public required WorldEntity Entity; public required Setup Setup; public required AnimationSequencer? Sequencer; public required uint[] PartGfxIds; public required IReadOnlyDictionary?[] SurfaceOverrides; public required bool[] PartAvailable; public PhysicsBody? Body; public AnimationSequencer? PendingProcessHooks; public ulong PendingResidencyVersion; public readonly List PreparedLivePartFrames = new(); public bool HasPreparedLivePartFrames; public AnimationSequencer? PreparedFrameSequencer; public ulong PreparedPresentationRevision; public LiveEntityAnimationState? LiveAnimation; public double ElapsedSinceUpdate; public readonly Frame RootFrameScratch = new(); public readonly List MeshRefs = new(); public readonly List PartPoses = new(); public readonly List VisualPartPoses = new(); } private readonly IAnimationLoader _animationLoader; private readonly Action _captureHooks; private readonly Action, IReadOnlyList> _publishPartPoses; private readonly Func _isResident; private readonly Action _commitLiveRoot; private readonly Func _residencyVersion; private readonly Dictionary _owners = new(); private readonly List _snapshot = new(); private readonly List _hookSnapshot = new(); public RetailStaticAnimatingObjectScheduler( IAnimationLoader animationLoader, Action captureHooks, Action, IReadOnlyList> publishPartPoses, Func? isResident = null, Action? commitLiveRoot = null, Func? residencyVersion = null) { _animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader)); _captureHooks = captureHooks ?? throw new ArgumentNullException(nameof(captureHooks)); _publishPartPoses = publishPartPoses ?? throw new ArgumentNullException(nameof(publishPartPoses)); _isResident = isResident ?? (_ => true); _commitLiveRoot = commitLiveRoot ?? ((_, _) => { }); _residencyVersion = residencyVersion ?? (_ => 0UL); } internal int Count => _owners.Count; public bool Register(WorldEntity entity, ScriptActivationInfo info) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(info); if (!info.UsesStaticAnimationWorkset || info.Setup is not { } setup || info.DefaultAnimationId == 0) { return false; } if (_owners.TryGetValue(entity.Id, out Owner? retained)) { if (ReferenceEquals(retained.Entity, entity)) return true; throw new InvalidOperationException( $"DAT-static animation owner 0x{entity.Id:X8} is already registered."); } // A live CPhysicsObj already owns its canonical PartArray through // AnimatedEntity. Resource registration occurs before that App owner // is constructed, so retain a pending workset member and bind the // exact sequencer later. DAT-only statics have no live owner and may // construct their PartArray here. AnimationSequencer? sequencer = null; if (entity.ServerGuid == 0) { sequencer = new AnimationSequencer( setup, new MotionTable(), _animationLoader); if (!sequencer.HasCurrentNode) return false; } int partCount = setup.Parts.Count; uint[] gfxIds = BuildPartGfxIds(setup, entity); bool[] available = new bool[partCount]; if (info.PartAvailability is { } supplied) { for (int i = 0; i < partCount && i < supplied.Count; i++) available[i] = supplied[i]; } var surfaces = MatchSurfaceOverrides(entity, gfxIds, available); _owners.Add(entity.Id, new Owner { Entity = entity, Setup = setup, Sequencer = sequencer, PartGfxIds = gfxIds, SurfaceOverrides = surfaces, PartAvailable = available, }); return true; } /// /// Rebinds the retained retail static-animation workset member to a fresh /// landblock snapshot without reconstructing its PartArray sequencer or /// restarting Setup.DefaultAnimation. /// public void Rebind(WorldEntity entity, ScriptActivationInfo info) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(info); if (!_owners.TryGetValue(entity.Id, out Owner? owner)) { throw new InvalidOperationException( $"DAT-static animation owner 0x{entity.Id:X8} is not registered."); } if (ReferenceEquals(owner.Entity, entity)) return; if (entity.ServerGuid != 0 || !info.UsesStaticAnimationWorkset || info.Setup is null || info.DefaultAnimationId == 0) { throw new InvalidOperationException( $"DAT-static animation owner 0x{entity.Id:X8} cannot be rebound to incompatible Setup data."); } int partCount = owner.Setup.Parts.Count; uint[] gfxIds = BuildPartGfxIds(owner.Setup, entity); bool[] available = new bool[partCount]; if (info.PartAvailability is { } supplied) { for (int i = 0; i < partCount && i < supplied.Count; i++) available[i] = supplied[i]; } // Compose publishes the owner's retained lists directly. Detach the // displaced immutable snapshot before those lists continue advancing // under the replacement entity. WorldEntity displaced = owner.Entity; if (ReferenceEquals(displaced.MeshRefs, owner.MeshRefs)) displaced.MeshRefs = owner.MeshRefs.ToArray(); if (ReferenceEquals(displaced.IndexedPartTransforms, owner.PartPoses) || ReferenceEquals(displaced.IndexedPartAvailable, owner.PartAvailable)) { displaced.SetIndexedPartPoses( owner.PartPoses.ToArray(), owner.PartAvailable.ToArray()); } owner.Entity = entity; owner.PartGfxIds = gfxIds; owner.SurfaceOverrides = MatchSurfaceOverrides(entity, gfxIds, available); owner.PartAvailable = available; owner.HasPreparedLivePartFrames = false; owner.PreparedLivePartFrames.Clear(); } /// /// Completes a live Physics-Static registration with the same sequencer /// and body owned by the canonical live entity. /// Retail's static workset stores a CPhysicsObj*; it never clones a /// second PartArray for this alternate scheduling path. /// public bool BindLiveOwner( WorldEntity entity, LiveEntityAnimationState animation, PhysicsBody body) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(animation); ArgumentNullException.ThrowIfNull(body); if (!_owners.TryGetValue(entity.Id, out Owner? owner)) return false; if (!ReferenceEquals(owner.Entity, entity) || entity.ServerGuid == 0) { throw new InvalidOperationException( $"Static animation owner 0x{entity.Id:X8} does not match this live incarnation."); } if (animation.Sequencer is not { HasCurrentNode: true } sequencer) return false; InvalidatePending(owner); owner.Sequencer = sequencer; owner.LiveAnimation = animation; owner.Body = body; return true; } /// /// Transfers the current live PartArray result to GameWindow's canonical /// appearance composer. The scheduler owns timing; AnimatedEntity remains /// the single owner of mesh identity, overrides, and appearance rebinding. /// public bool TryTakeLivePartFrames( LiveEntityRecord record, WorldEntity entity, LiveEntityAnimationState animation, ulong objectClockEpoch, ulong projectionMutationVersion, ulong presentationRevision, out IReadOnlyList frames) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(animation); uint ownerId = entity.Id; if (!_owners.TryGetValue(ownerId, out Owner? owner)) { frames = Array.Empty(); return false; } bool exactOwner = ReferenceEquals(record.WorldEntity, entity) && ReferenceEquals(record.AnimationRuntime, animation) && record.ObjectClockEpoch == objectClockEpoch && record.ProjectionMutationVersion == projectionMutationVersion && animation.PresentationRevision == presentationRevision && ReferenceEquals(owner.Entity, entity) && ReferenceEquals(owner.LiveAnimation, animation) && ReferenceEquals(owner.Sequencer, animation.Sequencer) && owner.Entity.ServerGuid != 0; if (!exactOwner) { InvalidatePending(owner); frames = Array.Empty(); return false; } // An already-consumed frame is a normal same-phase recursive query; // process_hooks still belongs to the outer presentation tail. if (!owner.HasPreparedLivePartFrames) { frames = Array.Empty(); return false; } if (!ReferenceEquals(owner.PreparedFrameSequencer, animation.Sequencer) || !IsResidentAtVersion(owner, owner.PendingResidencyVersion)) { InvalidatePending(owner); frames = Array.Empty(); return false; } if (owner.PreparedPresentationRevision != presentationRevision) { // Appearance rebinding invalidates only the prepared visual pose. // The exact live owner and sequencer still own process_hooks from // this quantum, including MotionDone, so retain that semantic tail. InvalidatePreparedFrame(owner); frames = Array.Empty(); return false; } owner.HasPreparedLivePartFrames = false; frames = owner.PreparedLivePartFrames; return true; } /// /// Scheduler-only test seam for DAT/static timing tests which deliberately /// do not construct a . Runtime integration /// must use the exact-incarnation overload above. /// internal bool TryTakePreparedFramesForTest( uint ownerId, out IReadOnlyList frames) { if (!_owners.TryGetValue(ownerId, out Owner? owner)) { frames = Array.Empty(); return false; } if (!owner.HasPreparedLivePartFrames) { frames = Array.Empty(); return false; } if (!IsResidentAtVersion(owner, owner.PendingResidencyVersion)) { InvalidatePending(owner); frames = Array.Empty(); return false; } owner.HasPreparedLivePartFrames = false; frames = owner.PreparedLivePartFrames; return true; } public void Unregister(uint ownerId) => _owners.Remove(ownerId); public void CopyAnimatedEntityIdsTo(HashSet destination) { ArgumentNullException.ThrowIfNull(destination); foreach ((uint ownerId, Owner owner) in _owners) { if (owner.Sequencer is not null) destination.Add(ownerId); } } public void Tick(float elapsedSeconds) { if (!float.IsFinite(elapsedSeconds) || elapsedSeconds <= 0f || _owners.Count == 0) { return; } _snapshot.Clear(); _snapshot.AddRange(_owners.Values); foreach (Owner owner in _snapshot) { if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current) || !ReferenceEquals(current, owner) || owner.Sequencer is not { } sequencer) { continue; } owner.ElapsedSinceUpdate += elapsedSeconds; if (!_isResident(owner.Entity)) { InvalidatePending(owner); // animate_static_object returns before touching update_time // while cell == null. A short leave-world interval therefore // catches up on re-entry; a long one is discarded by the // retail two-second guard below. continue; } ulong residencyVersion = _residencyVersion(owner.Entity); double ownerElapsed = owner.ElapsedSinceUpdate; owner.ElapsedSinceUpdate = 0d; if (ownerElapsed <= FrameEpsilon || ownerElapsed > MaximumElapsed) { continue; } IReadOnlyList frames = sequencer.Advance((float)ownerElapsed); owner.PreparedLivePartFrames.Clear(); for (int i = 0; i < frames.Count; i++) owner.PreparedLivePartFrames.Add(frames[i]); if (owner.Body is { } body) { // CPhysicsObj::animate_static_object 0x00513DF0 passes the // stored omega vector directly to Frame::grotate after the // PartArray update. Unlike UpdatePhysicsInternal, this odd // static branch does not multiply omega by elapsed time. Quaternion previousOrientation = body.Orientation; owner.RootFrameScratch.Origin = body.Position; owner.RootFrameScratch.Orientation = previousOrientation; FrameOps.GRotate(owner.RootFrameScratch, body.Omega); body.SetFrameInCurrentCell( body.Position, owner.RootFrameScratch.Orientation); owner.Entity.Rotation = body.Orientation; // animate_static_object rotates the root before // UpdatePartsInternal/UpdateChildrenInternal. Commit every // root consumer (collision shadows and effect anchors) at // that same boundary; a rendered-only rotation leaves the // canonical CPhysicsObj split across two orientations. // The common case is a zero omega. Do not turn that no-op // into a per-frame ShadowObjectRegistry reflood; only a real // root change has canonical consumers to commit. if (body.Orientation != previousOrientation) { _commitLiveRoot(owner.Entity, body); if (!_owners.TryGetValue(owner.Entity.Id, out current) || !ReferenceEquals(current, owner) || !IsResidentAtVersion(owner, residencyVersion)) { InvalidatePending(owner); continue; } } } if (!IsResidentAtVersion(owner, residencyVersion)) { InvalidatePending(owner); continue; } if (owner.Entity.ServerGuid != 0) { owner.HasPreparedLivePartFrames = true; owner.PreparedFrameSequencer = sequencer; owner.PreparedPresentationRevision = owner.LiveAnimation?.PresentationRevision ?? 0UL; } else { Compose(owner, owner.PreparedLivePartFrames); _publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable); if (!_owners.TryGetValue(owner.Entity.Id, out current) || !ReferenceEquals(current, owner) || !IsResidentAtVersion(owner, residencyVersion)) { InvalidatePending(owner); continue; } } // animate_static_object runs process_hooks only after the root, // parts, and attached children have their final transforms. Live // part/child publication occurs outside this owner, so retain the // exact PartArray and let GameWindow call ProcessHooks at that // later boundary instead of completing AnimationDone here. owner.PendingProcessHooks = sequencer; owner.PendingResidencyVersion = residencyVersion; } } /// /// Runs the static workset's retail process_hooks tail after final /// root, part, and child pose publication. Presentation hooks remain in /// the shared frame queue until its normal drain. /// public void ProcessHooks() { _hookSnapshot.Clear(); foreach (Owner owner in _owners.Values) { if (owner.PendingProcessHooks is not null) _hookSnapshot.Add(owner); } foreach (Owner owner in _hookSnapshot) { if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current) || !ReferenceEquals(current, owner) || owner.PendingProcessHooks is not { } sequencer || !ReferenceEquals(owner.Sequencer, sequencer) || !IsResidentAtVersion(owner, owner.PendingResidencyVersion)) { InvalidatePending(owner); continue; } // Clear before the callback: hook delivery may unregister or // replace the owner, and a nested caller must not replay this tail. owner.PendingProcessHooks = null; _captureHooks(owner.Entity.Id, sequencer); } } private bool IsResidentAtVersion(Owner owner, ulong version) => _isResident(owner.Entity) && _residencyVersion(owner.Entity) == version; private static void InvalidatePending(Owner owner) { InvalidatePreparedFrame(owner); owner.PendingProcessHooks = null; owner.PendingResidencyVersion = 0UL; } private static void InvalidatePreparedFrame(Owner owner) { owner.PreparedLivePartFrames.Clear(); owner.HasPreparedLivePartFrames = false; owner.PreparedFrameSequencer = null; owner.PreparedPresentationRevision = 0UL; } private static void Compose(Owner owner, IReadOnlyList frames) { EnsureRetainedPoses(owner); owner.MeshRefs.Clear(); Matrix4x4 objectScale = owner.Entity.Scale == 1f ? Matrix4x4.Identity : Matrix4x4.CreateScale(owner.Entity.Scale); int partCount = owner.Setup.Parts.Count; for (int i = 0; i < partCount; i++) { if (i < frames.Count) { Vector3 origin = frames[i].Origin; Quaternion orientation = frames[i].Orientation; Vector3 defaultScale = i < owner.Setup.DefaultScale.Count ? owner.Setup.DefaultScale[i] : Vector3.One; Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) * Matrix4x4.CreateFromQuaternion(orientation) * Matrix4x4.CreateTranslation(origin); if (owner.Entity.Scale != 1f) visual *= objectScale; owner.VisualPartPoses[i] = visual; owner.PartPoses[i] = Matrix4x4.CreateFromQuaternion(orientation) * Matrix4x4.CreateTranslation(origin * owner.Entity.Scale); } if (!owner.PartAvailable[i]) continue; owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], owner.VisualPartPoses[i]) { SurfaceOverrides = owner.SurfaceOverrides[i], }); } owner.Entity.MeshRefs = owner.MeshRefs; owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable); } private static void EnsureRetainedPoses(Owner owner) { int partCount = owner.Setup.Parts.Count; if (owner.VisualPartPoses.Count == partCount && owner.PartPoses.Count == partCount) { return; } owner.VisualPartPoses.Clear(); owner.PartPoses.Clear(); bool[] consumed = new bool[owner.Entity.MeshRefs.Count]; for (int i = 0; i < partCount; i++) { Matrix4x4 rigid = i < owner.Entity.IndexedPartTransforms.Count ? owner.Entity.IndexedPartTransforms[i] : Matrix4x4.Identity; owner.PartPoses.Add(rigid); Matrix4x4 visual = default; bool found = false; for (int meshIndex = 0; meshIndex < owner.Entity.MeshRefs.Count; meshIndex++) { MeshRef mesh = owner.Entity.MeshRefs[meshIndex]; if (consumed[meshIndex] || mesh.GfxObjId != owner.PartGfxIds[i]) continue; consumed[meshIndex] = true; visual = mesh.PartTransform; found = true; break; } if (!found) { Vector3 defaultScale = i < owner.Setup.DefaultScale.Count ? owner.Setup.DefaultScale[i] : Vector3.One; visual = Matrix4x4.CreateScale(defaultScale * owner.Entity.Scale) * rigid; } owner.VisualPartPoses.Add(visual); } } private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity) { var result = new uint[setup.Parts.Count]; for (int i = 0; i < result.Length; i++) result[i] = (uint)setup.Parts[i]; foreach (PartOverride replacement in entity.PartOverrides) { if (replacement.PartIndex < result.Length) result[replacement.PartIndex] = replacement.GfxObjId; } return result; } private static IReadOnlyDictionary?[] MatchSurfaceOverrides( WorldEntity entity, uint[] gfxIds, bool[] available) { var result = new IReadOnlyDictionary?[gfxIds.Length]; var consumed = new bool[entity.MeshRefs.Count]; for (int partIndex = 0; partIndex < gfxIds.Length; partIndex++) { for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++) { if (consumed[meshIndex] || entity.MeshRefs[meshIndex].GfxObjId != gfxIds[partIndex]) { continue; } consumed[meshIndex] = true; available[partIndex] = true; result[partIndex] = entity.MeshRefs[meshIndex].SurfaceOverrides; break; } } return result; } }