acdream/src/AcDream.Core/Physics/Motion/CSequence.cs
Erik 658b91d8aa feat(R1-P4): verbatim update_internal / update / advance_to_next_animation
The CSequence frame-advance core (gaps G3/G4/G5/G6/G8/G9/G19),
ACE-verified skeleton + retail constants + the P0-pinned leftover carry:

- update_internal (0x005255d0): frame_number += framerate*dt; overshoot
  clamps to the RAW high/low frame with leftover-time computation +
  animDone; the pose/physics/hook triple fires for EVERY crossed integer
  frame (ascending fwd / descending rev, strict > < boundaries, NO
  epsilon, NO safety cap); AnimDone is a LIST-STRUCTURE gate (head !=
  first_cyclic) queuing the global AnimDoneHook; iterative loop carries
  the leftover into the next node (P0 pin — a lag spike fast-forwards
  through multiple queued nodes in one tick).
- advance_to_next_animation (0x005252b0): four pose ops per transition
  (subtract1 outgoing @ current frame + residual physics; step — fwd
  wraps to first_cyclic, REVERSE wraps to the LIST TAIL, asymmetric by
  design; reseed from the incoming direction-aware boundary; combine
  incoming + physics). Pose ops run in BOTH directions — ACE's
  framerate-sign gates are ACE-isms, decomp is unconditional modulo the
  degenerate-framerate guard.
- update (0x00525b80): non-empty -> update_internal + apricot; empty +
  frame -> accumulated-physics free motion (G8).
- execute_hooks (0x00524830): direction filter (Both or match) QUEUING
  into the IAnimHookQueue host seam (stands in for CPhysicsObj.anim_hooks
  + the AnimDoneHook singleton; drain placement moves in R6). Null part
  frame guarded (documented safe divergence vs retail's latent deref).
- FrameOps.Combine/Subtract1: the AFrame pose composition (verified
  math from the pre-R1 port, now against DatReaderWriter Frame).

10 conformance tests: single-tick goldens, exact-integer boundary
sit (the old #61 flash class), cyclic wrap without AnimDone,
link->cycle fast-forward proving hook order (link hooks -> ANIMDONE ->
cycle hooks) AND the carry, reverse descending hooks with the OOB
high+1 start, reverse tail-wrap, zero-framerate physics-only,
empty-list free motion, pos-frame root motion into the caller Frame,
apricot trim via update. Full suite 3346 green.

R1 remaining: P5 (adapter cutover — AnimationSequencer rehosted on the
core, legacy epsilon/stale-head/safety-cap DELETED) + P6 (root-motion
wiring + API narrowing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:05:04 +02:00

511 lines
20 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
/// pose operations per transition — un-apply the outgoing node's pose
/// (subtract1 + residual physics), step (forward wraps to
/// <c>first_cyclic</c>; REVERSE wraps to the LIST TAIL — asymmetric by
/// design), reseed <see cref="FrameNumber"/> from the incoming node's
/// direction-aware boundary, apply the incoming pose (combine +
/// physics). Pose ops run in BOTH directions (ACE's framerate-sign
/// gates are ACE-isms; the decomp is unconditional apart from the
/// degenerate-framerate guard).
/// </summary>
public void AdvanceToNextAnimation(double timeElapsed, Frame? frame)
{
if (_currAnim is null)
return;
var outgoing = _currAnim.Value;
// (a) un-apply the outgoing node's pose at the CURRENT FrameNumber.
if (frame is not null && Math.Abs(outgoing.Framerate) >= FrameOps.FEpsilon)
{
var pos = outgoing.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Subtract1(frame, pos);
ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
}
// (b) step; (c) reseed FrameNumber from the incoming boundary.
if (timeElapsed >= 0.0)
{
_currAnim = _currAnim.Next ?? _firstCyclic;
if (_currAnim is null)
return;
FrameNumber = _currAnim.Value.GetStartingFrame();
}
else
{
_currAnim = _currAnim.Previous ?? _animList.Last;
if (_currAnim is null)
return;
FrameNumber = _currAnim.Value.GetEndingFrame();
}
// (d) apply the incoming node's pose at the new FrameNumber.
var incoming = _currAnim.Value;
if (frame is not null && Math.Abs(incoming.Framerate) >= FrameOps.FEpsilon)
{
var pos = incoming.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Combine(frame, pos);
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;
}