acdream/src/AcDream.Core/Physics/Motion/CSequence.cs
Erik 31a0889f08 fix(animation): restore retail transition sign gates
Match the v11.4186 CSequence transition assembly and ACE cross-reference, including direction-specific pose gates and the strict physics epsilon boundary. Add conformance tests and correct the stale research interpretation.

Co-authored-by: Codex <codex@openai.com>
2026-07-19 21:44:12 +02:00

528 lines
21 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.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R1-P4 host seam standing in for retail's <c>CPhysicsObj.anim_hooks</c>
/// SmartArray + the global <c>AnimDoneHook</c> singleton
/// (<c>add_anim_hook</c> 0x00514c20; <c>process_hooks</c> 0x00511550 drains
/// once per physics tick — the drain point stays with the host until R6
/// places it per retail's UpdateObjectInternal order).
/// </summary>
public interface IAnimHookQueue
{
/// <summary>Queue a matched AnimFrame hook (already direction-filtered
/// by <c>execute_hooks</c>).</summary>
void AddAnimHook(DatReaderWriter.Types.AnimationHook hook);
/// <summary>Queue the global animation-done hook — retail's
/// <c>AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone →
/// CPartArray::AnimationDone(1)</c> chain (R2 consumes it as
/// MotionDone).</summary>
void AddAnimDoneHook();
}
/// <summary>
/// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list
/// surgery (Phase R plan stage R1; oracle
/// `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` §1-§17,
/// §20, §24). The per-tick advance (`update`/`update_internal`/
/// `apply_physics`/hook dispatch) lands in R1-P3/P4.
///
/// Structure: a doubly-linked animation-node list with two cursors —
/// <see cref="CurrAnim"/> (the node currently playing) and
/// <see cref="FirstCyclic"/> (where the looping tail begins; everything
/// before it is one-shot "link" animation). Retail invariant (G10):
/// <c>append_animation</c> slides <c>first_cyclic</c> to the JUST-APPENDED
/// node on EVERY call — the cyclic tail is always exactly the last node
/// appended so far.
///
/// Physics accumulators (<see cref="Velocity"/>/<see cref="Omega"/>) live on
/// the SEQUENCE, not per node (G16); retail replaces them via
/// <c>set_velocity/set_omega</c> and algebraically blends via
/// <c>combine_physics/subtract_physics</c> (the R2 fast path's mechanism).
///
/// Divergence register (rows added with this commit):
/// <list type="bullet">
/// <item><c>frame_number</c> is x87 <c>long double</c> in retail
/// (acclient.h:30747); C# <c>double</c> is the closest available (G15).</item>
/// <item>The intrusive DLList is a managed <see cref="LinkedList{T}"/>;
/// node identity semantics preserved via <see cref="LinkedListNode{T}"/>
/// references.</item>
/// </list>
/// </summary>
public sealed class CSequence
{
private readonly LinkedList<AnimSequenceNode> _animList = new(); // anim_list (DLList)
private LinkedListNode<AnimSequenceNode>? _firstCyclic; // first_cyclic
private LinkedListNode<AnimSequenceNode>? _currAnim; // curr_anim
private readonly IAnimationLoader _loader;
/// <summary>Fractional frame position within <see cref="CurrAnim"/>.
/// Retail x87 long double → double (register row, G15).</summary>
public double FrameNumber;
/// <summary>Sequence root-motion velocity accumulator (body-local).</summary>
public Vector3 Velocity;
/// <summary>Sequence angular-velocity accumulator.</summary>
public Vector3 Omega;
/// <summary>Static pose used when no animation node is active
/// (<c>placement_frame</c>, §16).</summary>
public AnimationFrame? PlacementFrame { get; private set; }
public uint PlacementFrameId { get; private set; }
public CSequence(IAnimationLoader loader) => _loader = loader;
// ── inspection surface (adapter + tests) ────────────────────────────
public AnimSequenceNode? CurrAnim => _currAnim?.Value;
public AnimSequenceNode? FirstCyclic => _firstCyclic?.Value;
public int Count => _animList.Count;
/// <summary><c>has_anims</c> (0x00524bd0).</summary>
public bool HasAnims() => _animList.Count > 0;
/// <summary>TEST SEAM: reposition curr_anim by list index (retail state
/// reached via update_internal, which lands in P4).</summary>
public void SetCurrAnimForTest(int index)
{
var n = _animList.First;
for (int i = 0; i < index && n != null; i++) n = n.Next;
_currAnim = n;
}
// ── append_animation (0x00525510, §24) ──────────────────────────────
/// <summary>
/// Append a MotionData anim entry. A node whose dat animation fails to
/// resolve is discarded. <c>first_cyclic</c> slides to the appended
/// node on EVERY call; <c>curr_anim</c> seeds to the head (with
/// <c>frame_number = get_starting_frame()</c>) only when it was null.
/// </summary>
public void AppendAnimation(AnimData animData)
{
var node = new AnimSequenceNode(animData, _loader);
if (!node.HasAnim)
return; // retail deletes the node — discard
_animList.AddLast(node);
_firstCyclic = _animList.Last;
if (_currAnim is null)
{
_currAnim = _animList.First;
FrameNumber = _currAnim!.Value.GetStartingFrame();
}
}
// ── clear family (§3-§5) ────────────────────────────────────────────
/// <summary><c>clear</c> (0x005255b0): full wipe INCLUDING the placement
/// fields — the raw body resets them (the "2-instruction clear" note in
/// the gap map was wrong; raw decomp is authority).</summary>
public void Clear()
{
ClearAnimations();
ClearPhysics();
PlacementFrame = null;
PlacementFrameId = 0;
}
/// <summary><c>clear_animations</c> (0x00524dc0): delete every node,
/// null both cursors, zero <c>frame_number</c>.</summary>
public void ClearAnimations()
{
_animList.Clear();
_firstCyclic = null;
_currAnim = null;
FrameNumber = 0.0;
}
/// <summary><c>clear_physics</c> (0x00524d50).</summary>
public void ClearPhysics()
{
Velocity = Vector3.Zero;
Omega = Vector3.Zero;
}
// ── remove family (§6-§8) ───────────────────────────────────────────
/// <summary>
/// <c>remove_cyclic_anims</c> (0x00524e40): delete <c>first_cyclic</c>
/// → tail. A removed <c>curr_anim</c> snaps BACK to the previous node
/// with <c>frame_number = prev.get_ending_frame()</c> (or 0.0 when the
/// list emptied). Afterwards <c>first_cyclic</c> = new tail (or null).
/// </summary>
public void RemoveCyclicAnims()
{
var node = _firstCyclic;
while (node is not null)
{
var next = node.Next;
if (ReferenceEquals(_currAnim, node))
{
var prev = node.Previous;
_currAnim = prev;
FrameNumber = prev is null ? 0.0 : prev.Value.GetEndingFrame();
}
_animList.Remove(node);
node = next;
}
_firstCyclic = _animList.Last;
}
/// <summary>
/// <c>remove_link_animations(count)</c> (0x00524be0): delete up to
/// <paramref name="count"/> predecessors of <c>first_cyclic</c>. A
/// removed <c>curr_anim</c> snaps FORWARD to <c>first_cyclic</c> with
/// <c>frame_number = get_starting_frame()</c>.
/// </summary>
public void RemoveLinkAnimations(int count)
{
for (int i = 0; i < count; i++)
{
var prev = _firstCyclic?.Previous;
if (prev is null)
break;
if (ReferenceEquals(_currAnim, prev))
{
_currAnim = _firstCyclic;
if (_firstCyclic is not null)
FrameNumber = _firstCyclic.Value.GetStartingFrame();
}
_animList.Remove(prev);
}
}
/// <summary><c>remove_all_link_animations</c> (0x00524ca0): loop until
/// <c>first_cyclic</c> has no predecessor.</summary>
public void RemoveAllLinkAnimations()
{
while (_firstCyclic?.Previous is not null)
RemoveLinkAnimations(1);
}
/// <summary>
/// <c>apricot</c> (0x00524b40; the PDB-verified retail name): trim
/// consumed nodes from the head, bounded by BOTH <c>curr_anim</c>
/// (stop — still live) and <c>first_cyclic</c> (defensive bound —
/// never delete into the cyclic tail). Called after every update (§22).
/// </summary>
public void Apricot()
{
var head = _animList.First;
if (head is null || ReferenceEquals(head, _currAnim))
return;
while (!ReferenceEquals(head, _firstCyclic))
{
_animList.Remove(head!);
head = _animList.First;
if (head is null || ReferenceEquals(head, _currAnim))
break;
}
}
// ── physics accumulators (§10-§13) ──────────────────────────────────
public void SetVelocity(Vector3 v) => Velocity = v; // 0x00524880
public void SetOmega(Vector3 w) => Omega = w; // 0x005248a0
public void CombinePhysics(Vector3 v, Vector3 w) { Velocity += v; Omega += w; } // 0x005248c0
public void SubtractPhysics(Vector3 v, Vector3 w) { Velocity -= v; Omega -= w; } // 0x00524900
// ── multiply_cyclic_animation_fr (0x00524940, §14) ──────────────────
/// <summary>
/// Scale the framerate of every node from <c>first_cyclic</c> to the
/// tail. Framerates ONLY (G13) — retail rescales the sequence
/// velocity/omega separately via <c>change_cycle_speed</c>'s
/// <c>subtract_motion</c>/<c>combine_motion</c> composite (R2).
/// </summary>
public void MultiplyCyclicAnimationFramerate(float factor)
{
for (var n = _firstCyclic; n is not null; n = n.Next)
n.Value.MultiplyFramerate(factor);
}
// ── placement + accessors (§15-§17) ─────────────────────────────────
/// <summary><c>set_placement_frame</c> (0x005249b0).</summary>
public void SetPlacementFrame(AnimationFrame? frame, uint id)
{
PlacementFrame = frame;
PlacementFrameId = id;
}
/// <summary><c>get_curr_animframe</c> (0x00524970): the floored current
/// part frame, or the placement frame when no node is active.</summary>
public AnimationFrame? GetCurrAnimframe()
{
if (_currAnim is null)
return PlacementFrame;
return _currAnim.Value.GetPartFrame((int)Math.Floor(FrameNumber));
}
/// <summary><c>get_curr_frame_number</c> (0x005249d0).</summary>
public int GetCurrFrameNumber() => (int)Math.Floor(FrameNumber);
// ── apply_physics (0x00524ab0, §19) ─────────────────────────────────
/// <summary>
/// Accumulated-physics root motion: advance <paramref name="frame"/> by
/// <see cref="Velocity"/>/<see cref="Omega"/> over a signed quantum —
/// MAGNITUDE from <paramref name="quantum"/>, SIGN from
/// <paramref name="signSource"/> (retail copysign semantics; call sites
/// pass 1/framerate as magnitude and the signed elapsed time as sign).
/// </summary>
public void ApplyPhysics(Frame frame, double quantum, double signSource)
{
double signed = Math.Abs(quantum);
if (signSource < 0.0)
signed = -signed;
float sq = (float)signed;
frame.Origin += Velocity * sq;
FrameOps.Rotate(frame, Omega * sq);
}
// ── R1-P4: the frame-advance core ───────────────────────────────────
/// <summary>Host hook queue (<c>hook_obj</c>); null = hooks dropped
/// (objects without a physics host).</summary>
public IAnimHookQueue? HookObj;
/// <summary>
/// <c>CSequence::update</c> (0x00525b80, §22): non-empty list →
/// <see cref="UpdateInternal"/> then <see cref="Apricot"/>; empty list
/// with a Frame → accumulated-physics free motion.
/// </summary>
public void Update(double timeElapsed, Frame? frame)
{
if (_animList.Count > 0 && _currAnim is not null)
{
UpdateInternal(timeElapsed, frame);
Apricot();
}
else if (frame is not null)
{
ApplyPhysics(frame, timeElapsed, timeElapsed);
}
}
/// <summary>
/// <c>CSequence::update_internal</c> (0x005255d0, §21 — ACE-verified
/// skeleton, P0-pins.md): advance <see cref="FrameNumber"/> by
/// framerate·dt; on overshoot clamp to the RAW high/low frame, compute
/// the leftover time, and mark animDone; fire the pose/physics/hook
/// triple for EVERY crossed integer frame (ascending forward,
/// descending reverse); queue the global AnimDoneHook when the list
/// HEAD is no longer the cyclic tail (G5's structural gate); advance to
/// the next node and LOOP with the carried leftover (P0 pin).
/// Iterative (retail while-true), NO safety cap (G4), NO boundary
/// epsilon (G1/G3).
/// </summary>
public void UpdateInternal(double timeElapsed, Frame? frame)
{
while (true)
{
if (_currAnim is null)
return;
var currAnim = _currAnim.Value;
double framerate = currAnim.Framerate;
double frametime = framerate * timeElapsed;
int lastFrame = (int)Math.Floor(FrameNumber);
FrameNumber += frametime;
double frameTimeElapsed = 0.0;
bool animDone = false;
if (frametime > 0.0)
{
if (currAnim.HighFrame < Math.Floor(FrameNumber))
{
double frameOffset = FrameNumber - currAnim.HighFrame - 1.0;
if (frameOffset < 0.0)
frameOffset = 0.0;
if (Math.Abs(framerate) > FrameOps.FEpsilon)
frameTimeElapsed = frameOffset / framerate;
FrameNumber = currAnim.HighFrame;
animDone = true;
}
while (Math.Floor(FrameNumber) > lastFrame)
{
if (frame is not null)
{
var pos = currAnim.GetPosFrame(lastFrame);
if (pos is not null)
FrameOps.Combine(frame, pos);
if (Math.Abs(framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
}
ExecuteHooks(currAnim.GetPartFrame(lastFrame), +1);
lastFrame++;
}
}
else if (frametime < 0.0)
{
if (currAnim.LowFrame > Math.Floor(FrameNumber))
{
double frameOffset = FrameNumber - currAnim.LowFrame;
if (frameOffset > 0.0)
frameOffset = 0.0;
if (Math.Abs(framerate) > FrameOps.FEpsilon)
frameTimeElapsed = frameOffset / framerate;
FrameNumber = currAnim.LowFrame;
animDone = true;
}
while (Math.Floor(FrameNumber) < lastFrame)
{
if (frame is not null)
{
var pos = currAnim.GetPosFrame(lastFrame);
if (pos is not null)
FrameOps.Subtract1(frame, pos);
if (Math.Abs(framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
}
ExecuteHooks(currAnim.GetPartFrame(lastFrame), -1);
lastFrame--;
}
}
else
{
if (frame is not null && Math.Abs(timeElapsed) > FrameOps.FEpsilon)
ApplyPhysics(frame, timeElapsed, timeElapsed);
return;
}
if (!animDone)
return;
// AnimDone gate: a LIST-STRUCTURE test, not a node flag (G5) —
// fire only when the consumed head is not already the cyclic
// tail (retail 0x00525943-0x00525968).
if (HookObj is not null
&& _animList.First is not null
&& !ReferenceEquals(_animList.First, _firstCyclic))
{
HookObj.AddAnimDoneHook();
}
AdvanceToNextAnimation(timeElapsed, frame);
timeElapsed = frameTimeElapsed; // the P0-pinned leftover carry
}
}
/// <summary>
/// <c>CSequence::advance_to_next_animation</c> (0x005252b0, §23): four
/// sign-gated pose operations per transition. Positive elapsed time
/// removes only a reverse-playing outgoing pose, steps next (wrapping to
/// <c>first_cyclic</c>), and applies only a forward-playing incoming pose.
/// Negative elapsed time mirrors that over previous/list-tail. The
/// framerate sign gates are present in matching v11.4186 assembly and in
/// ACE's line-for-line port; physics has a separate strict
/// <c>abs(framerate) &gt; F_EPSILON</c> gate.
/// </summary>
public void AdvanceToNextAnimation(double timeElapsed, Frame? frame)
{
if (_currAnim is null)
return;
var outgoing = _currAnim.Value;
if (timeElapsed >= 0.0)
{
if (frame is not null && outgoing.Framerate < 0f)
{
var pos = outgoing.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Subtract1(frame, pos);
if (Math.Abs(outgoing.Framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
}
_currAnim = _currAnim.Next ?? _firstCyclic;
if (_currAnim is null)
return;
FrameNumber = _currAnim.Value.GetStartingFrame();
var incoming = _currAnim.Value;
if (frame is not null && incoming.Framerate > 0f)
{
var pos = incoming.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Combine(frame, pos);
if (Math.Abs(incoming.Framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed);
}
}
else
{
if (frame is not null && outgoing.Framerate >= 0f)
{
var pos = outgoing.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Subtract1(frame, pos);
if (Math.Abs(outgoing.Framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
}
_currAnim = _currAnim.Previous ?? _animList.Last;
if (_currAnim is null)
return;
FrameNumber = _currAnim.Value.GetEndingFrame();
var incoming = _currAnim.Value;
if (frame is not null && incoming.Framerate < 0f)
{
var pos = incoming.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Combine(frame, pos);
if (Math.Abs(incoming.Framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed);
}
}
}
/// <summary>
/// <c>CSequence::execute_hooks</c> (0x00524830, §18): QUEUE the part
/// frame's hooks whose direction is Both (0) or matches the playback
/// direction (+1 forward / 1 backward) into the host. Null part frame
/// guarded (retail has a latent null-deref here — documented safe
/// divergence, G18).
/// </summary>
private void ExecuteHooks(AnimationFrame? partFrame, int direction)
{
if (partFrame is null || HookObj is null)
return;
foreach (var hook in partFrame.Hooks)
{
if (hook is null)
continue;
int dir = (int)hook.Direction;
if (dir == 0 || dir == direction)
HookObj.AddAnimHook(hook);
}
}
// ── internal cursors for P4 (update_internal operates on nodes) ─────
internal LinkedListNode<AnimSequenceNode>? CurrAnimNode
{
get => _currAnim;
set => _currAnim = value;
}
internal LinkedListNode<AnimSequenceNode>? FirstCyclicNode => _firstCyclic;
internal LinkedList<AnimSequenceNode> AnimList => _animList;
}