acdream/src/AcDream.Core/Physics/AnimationSequencer.cs
Erik 78aef6d575 refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port
Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:

1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
   client binary. Previously float, which accumulated rounding error over long
   sessions.

2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
   negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
   from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.

3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
   forward and reverse; boundary detection uses EndFrame as the lower bound for
   reverse playback (matching the post-swap field semantics); remainder time
   propagates correctly across node boundaries for large dt values.

4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
   formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
   ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.

5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
   linked list is exhausted, matching the retail loop-forever semantics.

6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
   correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
   WalkBackward→WalkForward (negate×0.65 BackwardsFactor).

7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
   existing implementation is correct.

AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift

All 431 tests green (109 net + 322 core).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 12:59:32 +02:00

727 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Minimal interface for resolving Animation objects by id.
/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
/// </summary>
public interface IAnimationLoader
{
/// <summary>Load an Animation by its dat id, or return null.</summary>
Animation? LoadAnimation(uint id);
}
/// <summary>
/// Production implementation of <see cref="IAnimationLoader"/> backed by
/// a <see cref="DatCollection"/>.
/// </summary>
public sealed class DatCollectionLoader : IAnimationLoader
{
private readonly DatCollection _dats;
public DatCollectionLoader(DatCollection dats) => _dats = dats;
public Animation? LoadAnimation(uint id) => _dats.Get<Animation>(id);
}
// ─────────────────────────────────────────────────────────────────────────────
// AnimationSequencer — faithful port of the decompiled retail AC client
// animation system.
//
// Primary references (pseudocode at docs/research/acclient_animation_pseudocode.md):
// FUN_005267E0 — multiply_framerate: swaps startFrame↔endFrame for negative speed
// FUN_005261D0 — update_internal: the core per-frame advance loop
// FUN_00525EB0 — advance_to_next_animation: node transition + wrap to firstCyclic
// FUN_00526880 — GetStartFramePosition: double start pos (speed-dependent)
// 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
//
// DatReaderWriter types used:
// MotionTable.Links : Dictionary<int, MotionCommandData>
// key = (style << 16) | (fromSubstate & 0xFFFFFF)
// MotionCommandData.MotionData : Dictionary<int, MotionData>
// key = target motion (int cast of MotionCommand)
// MotionData.Anims : List<AnimData>
// AnimData.AnimId : QualifiedDataId<Animation>
// Animation.PartFrames : List<AnimationFrame>
// AnimationFrame.Frames : List<Frame>
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Per-part world-local transform produced by <see cref="AnimationSequencer.Advance"/>.
/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
/// </summary>
public readonly struct PartTransform
{
public readonly Vector3 Origin;
public readonly Quaternion Orientation;
public PartTransform(Vector3 origin, Quaternion orientation)
{
Origin = origin;
Orientation = orientation;
}
}
/// <summary>
/// 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)
/// </summary>
internal sealed class AnimNode
{
public Animation Anim;
public double Framerate; // signed; negative means reverse playback
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 AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping)
{
Anim = anim;
Framerate = framerate;
StartFrame = startFrame;
EndFrame = endFrame;
IsLooping = isLooping;
}
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
// Returns the initial framePosition cursor for this node.
// speedScale >= 0 → (double)startFrame
// speedScale < 0 → (double)(endFrame + 1) - EPSILON
// EPSILON = _DAT_007c92b4 (a tiny float just below the boundary)
public double GetStartFramePosition()
{
if (Framerate >= 0.0)
return (double)StartFrame;
else
return (double)(EndFrame + 1) - FrameEpsilon;
}
// ── FUN_005268B0 — GetEndFramePosition ───────────────────────────────
// Returns where the cursor sits when this node is exhausted.
// speedScale >= 0 → (double)(endFrame + 1) - EPSILON
// speedScale < 0 → (double)startFrame
public double GetEndFramePosition()
{
if (Framerate >= 0.0)
return (double)(EndFrame + 1) - FrameEpsilon;
else
return (double)StartFrame;
}
// Small double constant matching _DAT_007c92b4 in the retail binary.
// Used to position the cursor just before a frame boundary.
private const double FrameEpsilon = 1e-5;
}
/// <summary>
/// Full animation playback engine for one entity.
///
/// <para>
/// This is a faithful port of the retail AC client's Sequence object
/// (docs/research/acclient_animation_pseudocode.md, sections 57).
/// Key invariants:
/// <list type="bullet">
/// <item><description>
/// <c>_framePosition</c> is a <c>double</c> matching the retail client's
/// 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).
/// </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>
/// </list>
/// </para>
///
/// <para>
/// Usage pattern:
/// <code>
/// var seq = new AnimationSequencer(setup, motionTable, dats);
/// seq.SetCycle(style, motion, speedMod);
/// // each frame:
/// var transforms = seq.Advance(dt);
/// // rebuild MeshRefs from transforms
/// </code>
/// </para>
/// </summary>
public sealed class AnimationSequencer
{
// ── Public state ─────────────────────────────────────────────────────────
/// <summary>Current style (stance) command.</summary>
public uint CurrentStyle { get; private set; }
/// <summary>Current cyclic motion command.</summary>
public uint CurrentMotion { get; private set; }
// ── Private state ────────────────────────────────────────────────────────
private readonly Setup _setup;
private readonly MotionTable _mtable;
private readonly IAnimationLoader _loader;
// Animation queue: non-looping link frames followed by the looping cycle.
private readonly LinkedList<AnimNode> _queue = new();
private LinkedListNode<AnimNode>? _currNode;
private LinkedListNode<AnimNode>? _firstCyclic;
// 64-bit fractional frame position — matches Sequence+0x30 in the retail client.
// Named _framePosition to distinguish it from the old float _frameNum.
private double _framePosition;
private const double FrameEpsilon = 1e-5;
private const double RateEpsilon = 1e-6;
// ── Constructor ──────────────────────────────────────────────────────────
/// <summary>
/// Create a sequencer for one entity.
/// </summary>
/// <param name="setup">Entity's Setup dat (for part count / default scale).</param>
/// <param name="motionTable">Loaded MotionTable dat for this entity.</param>
/// <param name="loader">
/// Animation loader. Use <see cref="DatCollectionLoader"/> for production,
/// or inject a test double in unit tests.
/// </param>
public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(motionTable);
ArgumentNullException.ThrowIfNull(loader);
_setup = setup;
_mtable = motionTable;
_loader = loader;
}
// ── Public API ───────────────────────────────────────────────────────────
/// <summary>
/// Switch to a new cyclic motion, prepending any transition link frames
/// so the switch is smooth. If the motion table has no link for the
/// (currentStyle, currentMotion) → newMotion transition, the cycle
/// switches immediately.
///
/// <para>
/// Implements <c>adjust_motion</c> (ACE MotionInterp.cs:394-428): the AC
/// MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward.
/// These are played as their right-side / forward equivalents with a
/// negated framerate so the animation runs in reverse.
/// </para>
/// </summary>
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
public void SetCycle(uint style, uint motion, float speedMod = 1f)
{
// ── adjust_motion: remap left→right / backward→forward variants ───
// ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft,
// SideStepLeft, or WalkBackward cycles; the client plays the mirror
// animation with a negated speed so it runs backward.
uint adjustedMotion = motion;
float adjustedSpeed = speedMod;
switch (motion & 0xFFFFu)
{
case 0x000E: // TurnLeft → TurnRight (negate speed)
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
adjustedSpeed = -speedMod;
break;
case 0x0010: // SideStepLeft → SideStepRight (negate speed)
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
adjustedSpeed = -speedMod;
break;
case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor)
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE
break;
}
// Fast-path: already playing this exact motion at the same speed.
if (CurrentStyle == style && CurrentMotion == motion
&& _firstCyclic != null && _queue.Count > 0)
return;
// Resolve transition link (currentSubstate → adjustedMotion).
MotionData? linkData = CurrentMotion != 0
? GetLink(style, CurrentMotion, adjustedMotion)
: null;
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
// Clear the old cyclic tail; keep any non-cyclic head that hasn't
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
ClearCyclicTail();
// Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed, isLooping: false);
// Enqueue new cycle.
if (cycleData is { Anims.Count: > 0 })
{
EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true);
}
else if (_queue.Count == 0)
{
// No cycle and no link — nothing to play; reset fully.
_currNode = null;
_firstCyclic = null;
_framePosition = 0.0;
CurrentStyle = style;
CurrentMotion = motion;
return;
}
// Mark the first cyclic node (the looping tail after all link frames).
_firstCyclic = null;
for (var n = _queue.First; n != null; n = n.Next)
{
if (n.Value.IsLooping)
{
_firstCyclic = n;
break;
}
}
// If we have no current anim, start at the beginning of the queue.
if (_currNode == null)
{
_currNode = _queue.First;
_framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0;
}
CurrentStyle = style;
CurrentMotion = motion;
}
/// <summary>
/// Advance the animation by <paramref name="dt"/> seconds and return the
/// 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.
/// </para>
///
/// <para>
/// The slerp algorithm mirrors the decompiled retail client's
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799).
/// </para>
/// </summary>
/// <param name="dt">Elapsed time in seconds since the last call.</param>
/// <returns>
/// One <see cref="PartTransform"/> per part in the Setup, in part order.
/// If no animation is loaded, all parts get identity transforms.
/// </returns>
public IReadOnlyList<PartTransform> Advance(float dt)
{
int partCount = _setup.Parts.Count;
if (_currNode == null || dt <= 0f)
return BuildIdentityFrame(partCount);
// ── update_internal (FUN_005261D0) ───────────────────────────────
// Loop because a large dt can exhaust multiple nodes sequentially.
double timeRemaining = (double)dt;
while (timeRemaining > 0.0 && _currNode != null)
{
var curr = _currNode.Value;
double rate = curr.Framerate; // signed (negative = reverse)
double delta = rate * timeRemaining;
if (Math.Abs(delta) < RateEpsilon)
break; // rate ≈ 0 — nothing to do
double newPos = _framePosition + delta;
bool wrapped = false;
double overflow = 0.0;
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?
overflow = (newPos - maxBoundary) / rate;
if (overflow < 0.0) overflow = 0.0;
_framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos
wrapped = true;
}
else
{
_framePosition = newPos;
}
}
else
{
// ── REVERSE PLAYBACK ─────────────────────────────────────
// After FUN_005267E0 swaps low↔high for negative speed:
// StartFrame = high (e.g. 3), EndFrame = low (e.g. 0)
// GetStartFramePosition placed cursor at (EndFrame+1)-eps ≈ 0.99999.
// The cursor counts DOWN toward EndFrame. Boundary = EndFrame.
double minBoundary = (double)curr.EndFrame;
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
wrapped = true;
}
else
{
_framePosition = newPos;
}
}
if (!wrapped)
break; // consumed all dt without hitting boundary — done
// ── advance_to_next_animation (FUN_00525EB0) ─────────────────
AdvanceToNextAnimation();
timeRemaining = overflow; // continue with leftover time
}
return BuildBlendedFrame();
}
/// <summary>
/// Reset the sequencer to an unplaying state without clearing the
/// motion table reference.
/// </summary>
public void Reset()
{
_queue.Clear();
_currNode = null;
_firstCyclic = null;
_framePosition = 0.0;
CurrentStyle = 0;
CurrentMotion = 0;
}
// ── Private helpers ──────────────────────────────────────────────────────
/// <summary>
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
///
/// Port of ACE's MotionTable.get_link:
/// 1. Try Links[(style&lt;&lt;16)|(fromMotion&amp;0xFFFFFF)][toMotion]
/// 2. Fallback: try Links[style&lt;&lt;16][toMotion]
///
/// DatReaderWriter encodes Links as Dictionary&lt;int, MotionCommandData&gt;
/// where MotionCommandData.MotionData is Dictionary&lt;int, MotionData&gt;.
/// </summary>
private MotionData? GetLink(uint style, uint fromMotion, uint toMotion)
{
int outerKey1 = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1))
{
if (cmd1.MotionData.TryGetValue((int)toMotion, out var result1))
return result1;
}
// Fallback: style-level catch-all.
int outerKey2 = (int)(style << 16);
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2))
{
if (cmd2.MotionData.TryGetValue((int)toMotion, out var result2))
return result2;
}
return null;
}
/// <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)
{
uint animId = (uint)ad.AnimId;
if (animId == 0) return null;
var anim = _loader.LoadAnimation(animId);
if (anim is null || anim.PartFrames.Count == 0) return null;
int numFrames = anim.PartFrames.Count;
int low = ad.LowFrame;
int high = ad.HighFrame;
// Sentinel resolution (same as MotionResolver.GetIdleCycle).
if (high < 0) high = numFrames - 1;
if (low >= numFrames) low = numFrames - 1;
if (high >= numFrames) high = numFrames - 1;
if (low < 0) low = 0;
double fr = (double)ad.Framerate * (double)speedMod;
// ── FUN_005267E0 multiply_framerate ──────────────────────────────
// When speed is negative (TurnLeft→TurnRight, SideStepLeft→SideStepRight),
// swap Low↔High so the advance loop counts DOWN from the swapped EndFrame
// toward the swapped StartFrame. The pseudocode says:
// if speedScale < 0: swap startFrame ↔ endFrame
if (fr < 0.0)
{
(low, high) = (high, low);
// After swap: StartFrame > EndFrame (the loop detects delta < 0 and
// uses StartFrame as the lower boundary to count down toward).
}
else
{
if (low > high) high = low; // clamp for positive-speed case only
}
return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping);
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// queue. Each AnimData becomes one AnimNode.
/// </summary>
private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping)
{
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);
if (node != null)
_queue.AddLast(node);
}
}
/// <summary>
/// Remove all cyclic (looping) nodes from the tail of the queue starting
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain so they
/// can drain naturally.
/// </summary>
private void ClearCyclicTail()
{
if (_firstCyclic == null) return;
var node = _firstCyclic;
while (node != null)
{
var next = node.Next;
// If the active node is being removed, jump it to the preceding
// non-cyclic node (or reset if there is none).
if (_currNode == node)
{
_currNode = node.Previous;
if (_currNode != null)
_framePosition = _currNode.Value.GetEndFramePosition();
else
_framePosition = 0.0;
}
_queue.Remove(node);
node = next;
}
_firstCyclic = null;
}
/// <summary>
/// Move <see cref="_currNode"/> to the next node in the queue, or wrap
/// back to <see cref="_firstCyclic"/> when the queue is exhausted.
///
/// Implements <c>FUN_00525EB0</c> (Sequence::advance_to_next_animation).
/// The retail client walks a doubly-linked list; we mirror that with
/// LinkedList.Next plus the _firstCyclic wrap sentinel.
/// </summary>
private void AdvanceToNextAnimation()
{
if (_currNode == null) return;
LinkedListNode<AnimNode>? next = _currNode.Next;
if (next != null)
{
_currNode = next;
}
else if (_firstCyclic != null)
{
// Wrap to first cyclic node — this is the loop that keeps idle/walk
// animations playing forever.
_currNode = _firstCyclic;
}
// else: end of a finite non-looping sequence; stay on last node.
if (_currNode != null)
_framePosition = _currNode.Value.GetStartFramePosition();
}
/// <summary>
/// Build the per-part blended transform from the current animation frame.
/// Blends between floor(_framePosition) and floor(_framePosition)+1 using
/// the fractional part of _framePosition.
///
/// Uses the retail-client slerp (<see cref="SlerpRetailClient"/>) for
/// quaternion interpolation and linear lerp for position.
/// </summary>
private IReadOnlyList<PartTransform> BuildBlendedFrame()
{
int partCount = _setup.Parts.Count;
if (_currNode == null)
return BuildIdentityFrame(partCount);
var curr = _currNode.Value;
int numPartFrames = curr.Anim.PartFrames.Count;
// Clamp frameIndex to valid range.
int rangeLo = Math.Min(curr.StartFrame, curr.EndFrame);
int rangeHi = Math.Max(curr.StartFrame, curr.EndFrame);
rangeHi = Math.Min(rangeHi, numPartFrames - 1);
int frameIdx = (int)Math.Floor(_framePosition);
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
// Next frame for interpolation: step in the playback direction.
int nextIdx;
if (curr.Framerate >= 0.0)
{
nextIdx = frameIdx + 1;
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
nextIdx = rangeLo; // wrap forward
}
else
{
nextIdx = frameIdx - 1;
if (nextIdx < rangeLo)
nextIdx = rangeHi; // wrap backward
}
// Fractional blend weight (always in [0, 1]).
double rawT = _framePosition - Math.Floor(_framePosition);
float t = (float)Math.Clamp(rawT, 0.0, 1.0);
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
var result = new PartTransform[partCount];
for (int i = 0; i < partCount; i++)
{
if (i < f0Parts.Count)
{
var p0 = f0Parts[i];
var p1 = i < f1Parts.Count ? f1Parts[i] : p0;
result[i] = new PartTransform(
Vector3.Lerp(p0.Origin, p1.Origin, t),
SlerpRetailClient(p0.Orientation, p1.Orientation, t));
}
else
{
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
}
}
return result;
}
private static IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
{
var result = new PartTransform[partCount];
for (int i = 0; i < partCount; i++)
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
return result;
}
/// <summary>
/// Quaternion slerp matching the retail client's <c>FUN_005360d0</c>
/// (chunk_00530000.c:4799-4846):
/// <list type="number">
/// <item>Compute dot product of q1 and q2.</item>
/// <item>If dot &lt; 0, negate q2 (choose the shorter arc).</item>
/// <item>If 1 - dot &lt;= epsilon, fall back to (1-t)*q1 + t*q2 (linear).</item>
/// <item>Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).</item>
/// <item>Validate result lies in [0,1]²; if not, fall back to linear.</item>
/// </list>
/// The only difference from the standard formula is step 5: the retail
/// client validates that both blend weights are in [0,1] before using the
/// sin-based result; this handles degenerate inputs gracefully.
/// </summary>
public static Quaternion SlerpRetailClient(Quaternion q1, Quaternion q2, float t)
{
float dot = q1.W * q2.W + q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z;
// Step 2: choose the shorter arc.
Quaternion q2s;
if (dot < 0f)
{
dot = -dot;
q2s = new Quaternion(-q2.X, -q2.Y, -q2.Z, -q2.W);
}
else
{
q2s = q2;
}
const float SlerpEpsilon = 1e-4f;
float w1, w2;
if (1f - dot <= SlerpEpsilon)
{
// Near-parallel: linear fallback (matches retail client's path).
w1 = 1f - t;
w2 = t;
}
else
{
float omega = MathF.Acos(dot);
float sinOmega = MathF.Sin(omega);
float invSin = 1f / sinOmega;
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
float candidate2 = MathF.Sin(t * omega) * invSin;
// Step 5: validate (retail client check: both weights in [0,1]).
if (candidate1 >= 0f && candidate1 <= 1f
&& candidate2 >= 0f && candidate2 <= 1f)
{
w1 = candidate1;
w2 = candidate2;
}
else
{
w1 = 1f - t;
w2 = t;
}
}
return new Quaternion(
w1 * q1.X + w2 * q2s.X,
w1 * q1.Y + w2 * q2s.Y,
w1 * q1.Z + w2 * q2s.Z,
w1 * q1.W + w2 * q2s.W);
}
}