feat(anim): Phase E.1 motion hooks + PosFrames + velocity/omega surfacing

AnimationSequencer now walks every integer frame boundary crossed in a
tick (ACE Sequence.update_internal pattern), dispatching AnimationHook
objects whose Direction matches the playback direction (Forward or
Backward) or is Both. Mirrors ACE's Sequence.execute_hooks exactly.

New public API:
- ConsumePendingHooks() drains all hooks fired since last call, including
  AnimationDone sentinel on link-node drain (emote/attack completion).
- ConsumeRootMotionDelta() drains accumulated PosFrames root motion;
  AFrame.Combine (forward) / AFrame.Subtract (backward) applied per
  crossed frame to match retail.
- CurrentVelocity / CurrentOmega expose the active MotionData's velocity
  and omega (scaled by speedMod at enqueue), letting downstream physics
  integrate the animation-driven motion.

All 27 AnimationHookType variants (SoundHook, AttackHook,
CreateParticleHook, ReplaceObjectHook, DefaultScriptHook, SetOmegaHook,
TransparentHook, ScaleHook, SetLightHook, etc.) now flow through the
hook queue. Consumers in E.2/E.3 (audio + particles) will route them to
the right subsystems.

9 new tests cover: forward-hook crossing fires exactly once, Both-direction
fires in either direction, Forward-only suppressed on reverse playback,
Backward fires on reverse, PosFrames accumulation + drain, Velocity
exposure + speedMod scaling, AnimationDone fires on link drain.

Build green; 470 tests → 479 (361 Core + 9 new E.1 hook tests + 109 Net).

Ref: docs/research/deepdives/r03-motion-animation.md §5 (hooks), §7.1-7.2
(PosFrames), §7.3 (negative framerate).
Ref: ACE Sequence.cs:262 (execute_hooks), Sequence.cs:351-443
(update_internal per-frame crossing walk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 16:28:15 +02:00
parent d910d570a3
commit 4db0b2f16c
2 changed files with 580 additions and 34 deletions

View file

@ -41,6 +41,8 @@ public sealed class DatCollectionLoader : IAnimationLoader
// FUN_005268B0 — GetEndFramePosition: double end pos (speed-dependent)
// FUN_005360d0 — quaternion slerp with dot-product sign-flip
// MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping
// Sequence.cs:262-270 (ACE) — execute_hooks (Both or matching direction fires)
// Sequence.cs:351-443 (ACE) — update_internal with per-frame hook dispatch
//
// DatReaderWriter types used:
// MotionTable.Links : Dictionary<int, MotionCommandData>
@ -48,9 +50,14 @@ public sealed class DatCollectionLoader : IAnimationLoader
// MotionCommandData.MotionData : Dictionary<int, MotionData>
// key = target motion (int cast of MotionCommand)
// MotionData.Anims : List<AnimData>
// MotionData.Velocity / MotionData.Omega : Vector3 (world-space physics)
// MotionData.Flags : MotionDataFlags (HasVelocity=0x01, HasOmega=0x02)
// AnimData.AnimId : QualifiedDataId<Animation>
// Animation.PartFrames : List<AnimationFrame>
// Animation.PosFrames : List<Frame> (root motion, present if Flags & PosFrames)
// Animation.Flags : AnimationFlags (PosFrames = 0x01)
// AnimationFrame.Frames : List<Frame>
// AnimationFrame.Hooks : List<AnimationHook>
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
// ─────────────────────────────────────────────────────────────────────────────
@ -74,10 +81,10 @@ public readonly struct PartTransform
/// One entry in the animation queue (link transition or looping cycle).
///
/// Faithfully models the retail client AnimNode struct at +0x0C..+0x18.
/// When speedScale &lt; 0, startFrame and endFrame are swapped at construction
/// time (FUN_005267E0 / multiply_framerate) so the advance loop always has:
/// forward: startFrame ≤ endFrame (framePosition counts up)
/// reverse: startFrame ≥ endFrame (framePosition counts down)
/// Carries the parent <see cref="DatReaderWriter.Types.MotionData"/>'s
/// Velocity and Omega fields so per-tick physics deltas can be surfaced
/// while this node is current (ACE Sequence.Velocity / Omega equivalent
/// for the single-active-MotionData case).
/// </summary>
internal sealed class AnimNode
{
@ -86,14 +93,32 @@ internal sealed class AnimNode
public int StartFrame; // inclusive start frame (post-swap for negative speed)
public int EndFrame; // inclusive end frame (post-swap for negative speed)
public bool IsLooping; // true only for the tail cyclic node
public bool HasPosFrames; // mirror of Anim.Flags & AnimationFlags.PosFrames
public AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping)
// Carried from the source MotionData (one MotionData may produce N nodes;
// each carries the same vel/omega, and when the node becomes current the
// sequencer surfaces these values).
public Vector3 Velocity; // meters/sec, world-space
public Vector3 Omega; // radians/sec per axis
public AnimNode(
Animation anim,
double framerate,
int startFrame,
int endFrame,
bool isLooping,
bool hasPosFrames,
Vector3 velocity,
Vector3 omega)
{
Anim = anim;
Framerate = framerate;
StartFrame = startFrame;
EndFrame = endFrame;
IsLooping = isLooping;
HasPosFrames = hasPosFrames;
Velocity = velocity;
Omega = omega;
}
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
@ -139,13 +164,17 @@ internal sealed class AnimNode
/// 64-bit field at Sequence+0x30.
/// </description></item>
/// <item><description>
/// Negative framerate means reverse playback; startFrame/endFrame are
/// swapped at node construction time (FUN_005267E0).
/// Negative framerate means reverse playback.
/// </description></item>
/// <item><description>
/// When a node's frames are exhausted, <c>advance_to_next_animation</c>
/// wraps to <c>_firstCyclic</c> (the looping tail of the queue).
/// </description></item>
/// <item><description>
/// Every integer frame boundary crossed in a tick fires the hooks at
/// that frame whose <see cref="AnimationHookDir"/> matches the playback
/// direction (or <c>Both</c>). Mirrors ACE Sequence.execute_hooks.
/// </description></item>
/// </list>
/// </para>
///
@ -156,7 +185,8 @@ internal sealed class AnimNode
/// seq.SetCycle(style, motion, speedMod);
/// // each frame:
/// var transforms = seq.Advance(dt);
/// // rebuild MeshRefs from transforms
/// var hooks = seq.ConsumePendingHooks(); // fire audio / VFX / damage
/// var root = seq.ConsumeRootMotionDelta(); // add to AFrame if desired
/// </code>
/// </para>
/// </summary>
@ -170,6 +200,23 @@ public sealed class AnimationSequencer
/// <summary>Current cyclic motion command.</summary>
public uint CurrentMotion { get; private set; }
/// <summary>
/// World-space per-second velocity from the currently active
/// <see cref="MotionData"/> (Sequence.Velocity in retail). Zero when no
/// motion data carries a velocity. Scaled by <c>speedMod</c> at enqueue
/// time.
/// </summary>
public Vector3 CurrentVelocity =>
_currNode?.Value.Velocity ?? Vector3.Zero;
/// <summary>
/// Radians-per-second omega (axis-angle integration rate) from the
/// currently active <see cref="MotionData"/>. Scaled by <c>speedMod</c>
/// at enqueue time.
/// </summary>
public Vector3 CurrentOmega =>
_currNode?.Value.Omega ?? Vector3.Zero;
// Diagnostics
public int QueueCount => _queue.Count;
public bool HasCurrentNode => _currNode != null;
@ -189,6 +236,16 @@ public sealed class AnimationSequencer
// Named _framePosition to distinguish it from the old float _frameNum.
private double _framePosition;
// Hooks pending dispatch. Accumulated during Advance; drained via
// ConsumePendingHooks.
private readonly List<AnimationHook> _pendingHooks = new();
// Root motion (PosFrames) delta accumulated during Advance. Drained via
// ConsumeRootMotionDelta. Matches the retail client's AFrame.Combine /
// AFrame.Subtract chain in Sequence.update_internal.
private Vector3 _rootMotionPos;
private Quaternion _rootMotionRot = Quaternion.Identity;
private const double FrameEpsilon = 1e-5;
private const double RateEpsilon = 1e-6;
@ -321,15 +378,21 @@ public sealed class AnimationSequencer
/// per-part transforms for the current blended keyframe.
///
/// <para>
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0) in a
/// simplified form: no frame-trigger events (PhysicsObject not modelled
/// here), but correct boundary detection, remainder propagation, and
/// advance_to_next_animation wrapping.
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0 / ACE
/// Sequence.cs:351-443): walks every integer frame boundary crossed in
/// this tick, calls <c>execute_hooks</c> for each with the playback
/// direction, and accumulates <see cref="Animation.PosFrames"/> root
/// motion into the pending delta. Hooks fire only once per crossing
/// regardless of framerate scaling.
/// </para>
///
/// <para>
/// The slerp algorithm mirrors the decompiled retail client's
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799).
/// Crossing semantics (forward): as <c>floor(framePos)</c> increments
/// from <c>i</c> to <c>i+1</c>, hooks attached to frame <c>i</c> with
/// direction <c>Forward</c> or <c>Both</c> fire. Reverse: as
/// <c>floor(framePos)</c> decrements from <c>i</c> to <c>i-1</c>,
/// hooks with direction <c>Backward</c> or <c>Both</c> fire on frame
/// <c>i</c>.
/// </para>
/// </summary>
/// <param name="dt">Elapsed time in seconds since the last call.</param>
@ -344,11 +407,12 @@ public sealed class AnimationSequencer
if (_currNode == null || dt <= 0f)
return BuildIdentityFrame(partCount);
// ── update_internal (FUN_005261D0) ──────────────────────────────
// ── update_internal (FUN_005261D0 / ACE Sequence.update_internal)
// Loop because a large dt can exhaust multiple nodes sequentially.
double timeRemaining = (double)dt;
int safety = 64; // cap in case of a degenerate motion table
while (timeRemaining > 0.0 && _currNode != null)
while (timeRemaining > 0.0 && _currNode != null && safety-- > 0)
{
var curr = _currNode.Value;
double rate = curr.Framerate; // signed (negative = reverse)
@ -357,6 +421,9 @@ public sealed class AnimationSequencer
if (Math.Abs(delta) < RateEpsilon)
break; // rate ≈ 0 — nothing to do
// lastFrame = floor(_framePosition) BEFORE advance (ACE pattern).
int lastFrame = (int)Math.Floor(_framePosition);
double newPos = _framePosition + delta;
bool wrapped = false;
double overflow = 0.0;
@ -364,48 +431,65 @@ public sealed class AnimationSequencer
if (delta > 0.0)
{
// ── FORWARD PLAYBACK ──────────────────────────────────────
// End boundary = endFrame + 1. Pseudocode: floor(newPos) > maxFrame.
double maxBoundary = (double)(curr.EndFrame + 1);
if (newPos >= maxBoundary - FrameEpsilon)
{
// How much time spilled past the boundary?
// Time spilled past the boundary.
overflow = (newPos - maxBoundary) / rate;
if (overflow < 0.0) overflow = 0.0;
_framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos
_framePosition = maxBoundary - FrameEpsilon;
wrapped = true;
}
else
{
_framePosition = newPos;
}
// Walk every integer frame boundary crossed: apply posFrame
// delta and fire hooks with Forward direction.
while ((int)Math.Floor(_framePosition) > lastFrame)
{
ApplyPosFrame(curr, lastFrame, reverse: false);
ExecuteHooks(curr, lastFrame, AnimationHookDir.Forward);
lastFrame++;
}
}
else
{
// ── REVERSE PLAYBACK ─────────────────────────────────────
// No swap: StartFrame is still the LOW value (e.g. 0).
// GetStartFramePosition placed cursor at (EndFrame+1)-eps (near high).
// The cursor counts DOWN toward StartFrame.
double minBoundary = (double)curr.StartFrame;
if (newPos <= minBoundary)
{
// How much time spilled past the lower boundary?
overflow = (newPos - minBoundary) / rate;
if (overflow < 0.0) overflow = 0.0;
_framePosition = minBoundary; // clamp to lower boundary
_framePosition = minBoundary;
wrapped = true;
}
else
{
_framePosition = newPos;
}
// Walk every integer boundary crossed DOWN: subtract posFrame
// delta and fire hooks with Backward direction.
while ((int)Math.Floor(_framePosition) < lastFrame)
{
ApplyPosFrame(curr, lastFrame, reverse: true);
ExecuteHooks(curr, lastFrame, AnimationHookDir.Backward);
lastFrame--;
}
}
if (!wrapped)
break; // consumed all dt without hitting boundary — done
break; // consumed all dt without hitting node boundary — done
// ── advance_to_next_animation (FUN_00525EB0) ─────────────────
// Fire AnimationDone for any drained link node before wrap.
if (_currNode != null && !_currNode.Value.IsLooping)
_pendingHooks.Add(AnimationDoneSentinel);
AdvanceToNextAnimation();
timeRemaining = overflow; // continue with leftover time
}
@ -413,6 +497,37 @@ public sealed class AnimationSequencer
return BuildBlendedFrame();
}
/// <summary>
/// Retrieve and clear the list of hooks that fired since the last call.
/// Empty when no frame boundary was crossed. Safe to call multiple
/// times per frame; second and subsequent calls return an empty list.
/// </summary>
public IReadOnlyList<AnimationHook> ConsumePendingHooks()
{
if (_pendingHooks.Count == 0)
return Array.Empty<AnimationHook>();
var result = _pendingHooks.ToArray();
_pendingHooks.Clear();
return result;
}
/// <summary>
/// Retrieve and clear the root-motion displacement accumulated from
/// <see cref="Animation.PosFrames"/> during the last <see cref="Advance"/>
/// calls. Returns (Zero, Identity) when no PosFrames exist on the
/// current animation. The caller should combine this with their AFrame
/// (object placement) to propagate root motion — e.g. baked-in footsteps
/// on a running animation.
/// </summary>
public (Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()
{
var result = (_rootMotionPos, _rootMotionRot);
_rootMotionPos = Vector3.Zero;
_rootMotionRot = Quaternion.Identity;
return result;
}
/// <summary>
/// Play a one-shot action/modifier motion (Jump, emote, attack, etc.)
/// on top of the current cycle. The action frames are inserted in the
@ -479,10 +594,15 @@ public sealed class AnimationSequencer
// Build AnimNodes from the action's AnimData list. All non-looping —
// they drain once, then the queue falls through to _firstCyclic.
Vector3 vel = data.Flags.HasFlag(MotionDataFlags.HasVelocity)
? data.Velocity * speedMod : Vector3.Zero;
Vector3 omg = data.Flags.HasFlag(MotionDataFlags.HasOmega)
? data.Omega * speedMod : Vector3.Zero;
var newNodes = new List<AnimNode>(data.Anims.Count);
for (int i = 0; i < data.Anims.Count; i++)
{
var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false);
var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false, vel, omg);
if (node != null) newNodes.Add(node);
}
if (newNodes.Count == 0) return;
@ -529,12 +649,20 @@ public sealed class AnimationSequencer
_currNode = null;
_firstCyclic = null;
_framePosition = 0.0;
_pendingHooks.Clear();
_rootMotionPos = Vector3.Zero;
_rootMotionRot = Quaternion.Identity;
CurrentStyle = 0;
CurrentMotion = 0;
}
// ── Private helpers ──────────────────────────────────────────────────────
// Sentinel hook fired when a non-cyclic link node drains naturally.
// Mirrors ACE's PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook).
private static readonly AnimationDoneHook AnimationDoneSentinel =
new() { Direction = AnimationHookDir.Both };
/// <summary>
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
@ -569,12 +697,13 @@ public sealed class AnimationSequencer
/// <summary>
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
///
/// Implements <c>FUN_005267E0</c> (multiply_framerate): when
/// <c>fr &lt; 0</c>, startFrame and endFrame are swapped so the advance
/// loop's boundary logic works uniformly for both directions.
/// </summary>
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
private AnimNode? LoadAnimNode(
AnimData ad,
float speedMod,
bool isLooping,
Vector3 velocity,
Vector3 omega)
{
uint animId = (uint)ad.AnimId;
if (animId == 0) return null;
@ -602,19 +731,37 @@ public sealed class AnimationSequencer
// from high→low instead of being stuck in [0,1).
if (low > high) high = low;
return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping);
bool hasPosFrames = anim.Flags.HasFlag(AnimationFlags.PosFrames)
&& anim.PosFrames.Count >= numFrames;
return new AnimNode(
anim,
fr,
startFrame: low,
endFrame: high,
isLooping,
hasPosFrames,
velocity,
omega);
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// queue. Each AnimData becomes one AnimNode.
/// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the
/// MotionData are applied to every resulting node so they remain active
/// while the node is current.
/// </summary>
private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping)
{
Vector3 vel = motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)
? motionData.Velocity * speedMod : Vector3.Zero;
Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega)
? motionData.Omega * speedMod : Vector3.Zero;
for (int i = 0; i < motionData.Anims.Count; i++)
{
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);
var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling);
var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling, vel, omg);
if (node != null)
_queue.AddLast(node);
}
@ -680,6 +827,58 @@ public sealed class AnimationSequencer
_framePosition = _currNode.Value.GetStartFramePosition();
}
/// <summary>
/// Dispatch any hooks on the given part frame whose direction matches
/// the playback direction (or <c>Both</c>). Mirrors ACE's
/// <c>Sequence.execute_hooks</c> (Sequence.cs:262).
/// </summary>
private void ExecuteHooks(AnimNode node, int frameIndex, AnimationHookDir playbackDir)
{
if (frameIndex < 0 || frameIndex >= node.Anim.PartFrames.Count) return;
var frame = node.Anim.PartFrames[frameIndex];
if (frame.Hooks.Count == 0) return;
for (int i = 0; i < frame.Hooks.Count; i++)
{
var hook = frame.Hooks[i];
if (hook == null) continue;
// ACE: hook.Direction == Both || hook.Direction == playbackDir
if (hook.Direction == AnimationHookDir.Both
|| hook.Direction == playbackDir)
{
_pendingHooks.Add(hook);
}
}
}
/// <summary>
/// Apply the <see cref="Animation.PosFrames"/> (root motion) delta for
/// <paramref name="frameIndex"/> to the accumulated pending delta.
/// Mirrors ACE's <c>AFrame.Combine</c> (forward) / <c>frame.Subtract</c>
/// (backward) calls in <c>update_internal</c>.
/// </summary>
private void ApplyPosFrame(AnimNode node, int frameIndex, bool reverse)
{
if (!node.HasPosFrames) return;
var posFrames = node.Anim.PosFrames;
if (frameIndex < 0 || frameIndex >= posFrames.Count) return;
var pf = posFrames[frameIndex];
if (!reverse)
{
// AFrame.Combine: position += rot.Rotate(pf.Origin); rot *= pf.Orientation
_rootMotionPos += Vector3.Transform(pf.Origin, _rootMotionRot);
_rootMotionRot = Quaternion.Normalize(_rootMotionRot * pf.Orientation);
}
else
{
// AFrame.Subtract: rot *= conj(pf.Orientation); position -= rot.Rotate(pf.Origin)
var invRot = Quaternion.Conjugate(pf.Orientation);
_rootMotionRot = Quaternion.Normalize(_rootMotionRot * invRot);
_rootMotionPos -= Vector3.Transform(pf.Origin, _rootMotionRot);
}
}
/// <summary>
/// Build the per-part blended transform from the current animation frame.
/// Blends between floor(_framePosition) and floor(_framePosition)+1 using