acdream/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

133 lines
5 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Defers animation-hook delivery until every entity and equipped child has
/// published its final pose for the frame.
/// </summary>
/// <remarks>
/// Retail stages hooks during sequence advance, then the same object update
/// commits the final part frame and runs <c>CPhysicsObj::UpdateChild</c>
/// (<c>0x00512D50</c>) before particles advance or anything draws. Capturing
/// here preserves that observable order and avoids firing a hand or weapon
/// effect against the previous animation frame.
/// </remarks>
public sealed class AnimationHookFrameQueue
{
private readonly AnimationHookRouter _router;
private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectPoseLifetimeSource? _poseLifetimes;
private readonly List<Entry> _entries = new();
public AnimationHookFrameQueue(
AnimationHookRouter router,
IEntityEffectPoseSource poses)
{
_router = router ?? throw new ArgumentNullException(nameof(router));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_poseLifetimes = poses as IEntityEffectPoseLifetimeSource;
}
public int Count => _entries.Count;
public void Capture(uint ownerLocalId, AnimationSequencer sequencer)
{
ArgumentNullException.ThrowIfNull(sequencer);
Capture(ownerLocalId, sequencer, sequencer.ConsumePendingHooks());
}
internal void Capture(
uint ownerLocalId,
AnimationSequencer sequencer,
IReadOnlyList<AnimationHook> hooks)
{
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(hooks);
// Capture incarnation identity before AnimationDone: its semantic
// callback can synchronously delete this owner and reuse the local ID.
// Reading the version afterwards would mislabel the old hook batch as
// belonging to the replacement.
ulong ownerLifetimeVersion =
_poseLifetimes?.GetPoseOwnerLifetimeVersion(ownerLocalId) ?? 0UL;
// Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
// inside UpdatePositionInternal, before the transition and every
// UpdateObjectInternal manager. Complete the semantic motion state at
// capture time so that this object's later Target/Movement/PartArray
// tail sees the new queue in the same quantum. The hook remains in the
// deferred entry so presentation sinks still observe the complete
// authored hook stream after the final part poses are published.
for (int i = 0; i < hooks.Count; i++)
{
// MotionDone can synchronously delete this owner and reuse the
// local ID. Do not advance the displaced sequencer for later
// AnimDone hooks from the old catch-up batch.
if (_poseLifetimes is not null
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
ownerLocalId) != ownerLifetimeVersion)
{
break;
}
if (hooks[i] is AnimationDoneHook)
sequencer.Manager.AnimationDone(success: true);
}
if (hooks.Count == 0)
return;
_entries.Add(new Entry(
ownerLocalId,
ownerLifetimeVersion,
hooks));
}
public void Drain()
{
for (int i = 0; i < _entries.Count; i++)
{
Entry entry = _entries[i];
if (_poseLifetimes is not null
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
{
continue;
}
for (int hi = 0; hi < entry.Hooks.Count; hi++)
{
// A prior hook sink can tear down and replace this local ID.
// Revalidate per hook so the remainder of the old PES/animation
// batch can never spill into the replacement incarnation.
if (_poseLifetimes is not null
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
{
break;
}
AnimationHook? hook = entry.Hooks[hi];
if (hook is null)
continue;
if (_poses.TryGetRootPose(
entry.OwnerLocalId,
out Matrix4x4 rootWorld))
{
_router.OnHook(
entry.OwnerLocalId,
rootWorld.Translation,
hook);
}
}
}
_entries.Clear();
}
public void Clear() => _entries.Clear();
private readonly record struct Entry(
uint OwnerLocalId,
ulong OwnerLifetimeVersion,
IReadOnlyList<AnimationHook> Hooks);
}