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>
This commit is contained in:
Erik 2026-07-02 20:05:04 +02:00
parent 5138b8fb01
commit 658b91d8aa
3 changed files with 518 additions and 0 deletions

View file

@ -6,6 +6,26 @@ 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
@ -273,6 +293,211 @@ public sealed class CSequence
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

View file

@ -55,4 +55,28 @@ public static class FrameOps
/// global through the orientation, then <see cref="GRotate"/>.</summary>
public static void Rotate(Frame frame, Vector3 rotationLocal)
=> GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation));
/// <summary>
/// <c>Frame::combine</c> as used by the CSequence pose path (ACE
/// <c>AFrame.Combine</c>, verified against the pre-R1 acdream port):
/// apply the pose — origin += rotate(pos.Origin by orientation), then
/// orientation ∘= pos.Orientation.
/// </summary>
public static void Combine(Frame frame, Frame pos)
{
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
}
/// <summary>
/// <c>Frame::subtract1</c> — un-apply the pose: orientation ∘=
/// conj(pos.Orientation) FIRST, then origin = rotate(pos.Origin by the
/// UPDATED orientation) (inverse order of <see cref="Combine"/>).
/// </summary>
public static void Subtract1(Frame frame, Frame pos)
{
frame.Orientation = Quaternion.Normalize(
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
}
}