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>
133 lines
5 KiB
C#
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);
|
|
}
|