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; 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> /// <summary>
/// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list /// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list
/// surgery (Phase R plan stage R1; oracle /// surgery (Phase R plan stage R1; oracle
@ -273,6 +293,211 @@ public sealed class CSequence
FrameOps.Rotate(frame, Omega * 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 cursors for P4 (update_internal operates on nodes) ─────
internal LinkedListNode<AnimSequenceNode>? CurrAnimNode internal LinkedListNode<AnimSequenceNode>? CurrAnimNode

View file

@ -55,4 +55,28 @@ public static class FrameOps
/// global through the orientation, then <see cref="GRotate"/>.</summary> /// global through the orientation, then <see cref="GRotate"/>.</summary>
public static void Rotate(Frame frame, Vector3 rotationLocal) public static void Rotate(Frame frame, Vector3 rotationLocal)
=> GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation)); => 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);
}
} }

View file

@ -0,0 +1,269 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R1-P4 — the frame-advance core: <c>update_internal</c> (0x005255d0),
/// <c>update</c> (0x00525b80), <c>advance_to_next_animation</c>
/// (0x005252b0), <c>execute_hooks</c> (0x00524830) — gaps
/// G3/G4/G5/G6/G8/G9/G19.
///
/// Skeleton per the ACE-verified structure (P0-pins.md): overshoot →
/// clamp to the RAW high/low frame + leftover carry + animDone →
/// per-crossed-frame pose/physics/hook loop → AnimDone gate
/// (list HEAD != first_cyclic) → advance (pose-out both directions,
/// forward wraps to first_cyclic, reverse wraps to the LIST TAIL) →
/// carry the leftover (P0 pin) → loop. NO safety cap, NO boundary
/// epsilon.
/// </summary>
public class CSequenceUpdateTests
{
private sealed class MapLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _map = new();
public void Add(uint id, Animation anim) => _map[id] = anim;
public Animation? LoadAnimation(uint id) => _map.TryGetValue(id, out var a) ? a : null;
}
private sealed class RecordingHookQueue : IAnimHookQueue
{
public readonly List<string> Events = new();
public void AddAnimHook(AnimationHook hook)
=> Events.Add($"hook:{((TaggedHook)hook).Tag}:{hook.Direction}");
public void AddAnimDoneHook() => Events.Add("ANIMDONE");
}
/// <summary>Hook subclass carrying a test tag (frame index).</summary>
private sealed class TaggedHook : AnimationHook
{
public string Tag = "";
public override AnimationHookType HookType => (AnimationHookType)0;
}
/// <summary>numFrames frames; one Forward-direction tagged hook per frame.</summary>
private static Animation MakeAnim(int numFrames, bool posFrames = false, string hookPrefix = "f")
{
var anim = new Animation();
for (int f = 0; f < numFrames; f++)
{
var pf = new AnimationFrame(1u);
pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity });
pf.Hooks.Add(new TaggedHook { Tag = $"{hookPrefix}{f}", Direction = AnimationHookDir.Both });
anim.PartFrames.Add(pf);
if (posFrames)
anim.PosFrames.Add(new Frame { Origin = new Vector3(1, 0, 0), Orientation = Quaternion.Identity });
}
return anim;
}
private static AnimData Ad(uint animId, float framerate = 30f, int low = 0, int high = -1)
{
QualifiedDataId<Animation> qid = animId;
return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate };
}
private static (CSequence seq, RecordingHookQueue hooks) Cyclic(int frames, float framerate = 30f)
{
var loader = new MapLoader();
loader.Add(1u, MakeAnim(frames));
var seq = new CSequence(loader);
var hooks = new RecordingHookQueue();
seq.HookObj = hooks;
seq.AppendAnimation(Ad(1u, framerate));
return (seq, hooks);
}
// ── forward advance basics (G3) ─────────────────────────────────────
[Fact]
public void Forward_SingleTick_AdvancesOneFrame_FiresExitedFrameHook()
{
var (seq, hooks) = Cyclic(5);
seq.Update(1.0 / 30.0, null);
Assert.Equal(1.0, seq.FrameNumber, 9);
Assert.Equal(new[] { "hook:f0:Both" }, hooks.Events);
}
[Fact]
public void Forward_ExactBoundaryLanding_NoAdvance()
{
// Boundary test is STRICT (>): landing exactly at high_frame+0.0
// integer positions must NOT advance (the G3 bug class: acdream's
// old epsilon-shifted boundary reclassified these).
var (seq, hooks) = Cyclic(5);
seq.FrameNumber = 3.5;
seq.Update(0.5 / 30.0, null); // lands exactly at 4.0 == high_frame
Assert.Equal(4.0, seq.FrameNumber, 9);
Assert.Equal(new[] { "hook:f3:Both" }, hooks.Events); // crossed 3→4
Assert.DoesNotContain("ANIMDONE", hooks.Events);
}
[Fact]
public void Forward_CyclicWrap_NoAnimDone_RestartsAtStartingFrame()
{
// Single cyclic node: overshoot wraps to itself via first_cyclic;
// head == first_cyclic → NO AnimDoneHook (G5's structural gate).
var (seq, hooks) = Cyclic(3);
seq.FrameNumber = 2.0;
seq.Update(1.0 / 30.0, null); // 2→3 > high(2) → clamp, advance, wrap
Assert.Equal(0.0, seq.FrameNumber, 9);
Assert.DoesNotContain("ANIMDONE", hooks.Events);
Assert.Same(seq.FirstCyclic, seq.CurrAnim);
}
// ── multi-node fast-forward + the P0-pinned carry (G3/G5) ───────────
[Fact]
public void Forward_LinkToCycle_FiresAnimDone_AndCarriesLeftover()
{
// link (2 frames) + cycle (4 frames): a 0.1s tick (3 frames at
// 30fps) exhausts the link (2 frames) and carries 1 frame of time
// into the cycle. Retail order: link's crossed-frame hooks →
// AnimDoneHook (head != first_cyclic) → cycle's crossed hooks.
var loader = new MapLoader();
loader.Add(1u, MakeAnim(2, hookPrefix: "link"));
loader.Add(2u, MakeAnim(4, hookPrefix: "cyc"));
var seq = new CSequence(loader);
var hooks = new RecordingHookQueue();
seq.HookObj = hooks;
seq.AppendAnimation(Ad(1u)); // link — first_cyclic slides…
seq.AppendAnimation(Ad(2u)); // …to the cycle
seq.Update(0.1, null); // 3 frames of time
// link: FrameNumber 0→3, clamp to high(1), leftover = 1 frame;
// crossing fires link f0 only (floor(1)>0), then AnimDone, then
// advance → cycle at starting(0); carry 1/30 → cycle 0→1 fires cyc f0.
Assert.Equal(new[] { "hook:link0:Both", "ANIMDONE", "hook:cyc0:Both" }, hooks.Events);
Assert.Equal(1.0, seq.FrameNumber, 6); // the carry (P0 pin) — zeroing would leave 0.0
Assert.Same(seq.FirstCyclic, seq.CurrAnim); // now inside the cycle
}
// ── reverse playback (G3/G9) ────────────────────────────────────────
[Fact]
public void Reverse_DescendingHooks_BackwardDirection()
{
// Natively-reverse node (framerate 30): seeded at starting =
// high+1 = 5. Each tick fires the hook of the frame being LEFT
// (the pre-tick floor index), descending; the very first tick's
// index (5 = high+1) is OOB → null part frame → no hook, exactly
// the retail structure.
var (seq, hooks) = Cyclic(5, framerate: -30f);
Assert.Equal(5.0, seq.FrameNumber, 9); // get_starting_frame = high+1
seq.Update(1.0 / 30.0, null); // 5→4 (leaves OOB index 5 — nothing)
seq.Update(1.0 / 30.0, null); // 4→3 (leaves frame 4)
seq.Update(1.0 / 30.0, null); // 3→2 (leaves frame 3)
Assert.Equal(2.0, seq.FrameNumber, 9);
Assert.Equal(new[] { "hook:f4:Both", "hook:f3:Both" }, hooks.Events);
}
[Fact]
public void Reverse_AdvanceWrapsToListTail()
{
// Two nodes A(reverse),B(forward): exhausting A backward wraps
// curr_anim to the LIST TAIL (B) — retail's asymmetric wrap (G9).
var loader = new MapLoader();
loader.Add(1u, MakeAnim(3, hookPrefix: "a"));
loader.Add(2u, MakeAnim(4, hookPrefix: "b"));
var seq = new CSequence(loader);
var hooks = new RecordingHookQueue();
seq.HookObj = hooks;
seq.AppendAnimation(Ad(1u, framerate: -30f)); // A — curr seeds here
seq.AppendAnimation(Ad(2u)); // B = first_cyclic = tail
seq.FrameNumber = 0.5; // mid-window of A
seq.Update(0.02, null); // frametime = 0.6 → crosses low(0)
// After the reverse advance, curr must have left A via the TAIL
// wrap (B), whatever the carry then does within B.
Assert.NotNull(seq.CurrAnim);
Assert.Equal(3, seq.CurrAnim!.HighFrame); // B (4 frames → high 3)
}
// ── stationary / degenerate (G8 + else-branch) ──────────────────────
[Fact]
public void ZeroFramerate_PhysicsOnlyAndReturn()
{
var (seq, hooks) = Cyclic(3, framerate: 0f);
seq.SetVelocity(new Vector3(2, 0, 0));
var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
seq.Update(0.5, frame);
// frametime == 0 → apply_physics(frame, elapsed, elapsed) then return.
Assert.Equal(1.0f, frame.Origin.X, 5);
Assert.Empty(hooks.Events);
Assert.Equal(0.0, seq.FrameNumber, 9);
}
[Fact]
public void EmptyList_Update_AppliesAccumulatedPhysics()
{
// update (0x00525b80): empty anim_list + frame != null →
// apply_physics(frame, elapsed, elapsed) — free-fall/knockback
// with no animation (G8).
var seq = new CSequence(new MapLoader());
seq.SetVelocity(new Vector3(0, 3, 0));
var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
seq.Update(2.0, frame);
Assert.Equal(6.0f, frame.Origin.Y, 5);
}
// ── root motion into the caller Frame (G7 wiring half) ──────────────
[Fact]
public void Forward_PosFrames_CombinedIntoCallerFrame()
{
// Each crossed frame combines the node's pos_frame into the caller
// Frame (retail root motion): 2 crossed frames → +2 X.
var loader = new MapLoader();
loader.Add(1u, MakeAnim(5, posFrames: true));
var seq = new CSequence(loader);
seq.AppendAnimation(Ad(1u));
var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
seq.Update(2.0 / 30.0, frame); // crosses frames 0 and 1
Assert.Equal(2.0f, frame.Origin.X, 5);
Assert.Equal(2.0, seq.FrameNumber, 9);
}
// ── update entry contract (G19) ─────────────────────────────────────
[Fact]
public void Update_RunsApricot_TrimmingConsumedLinkNodes()
{
// After the link→cycle advance, apricot (called by update) frees
// the consumed link node.
var loader = new MapLoader();
loader.Add(1u, MakeAnim(2, hookPrefix: "link"));
loader.Add(2u, MakeAnim(4, hookPrefix: "cyc"));
var seq = new CSequence(loader);
seq.AppendAnimation(Ad(1u));
seq.AppendAnimation(Ad(2u));
Assert.Equal(2, seq.Count);
seq.Update(0.1, null); // exhausts the link, advances into the cycle
Assert.Equal(1, seq.Count); // link trimmed by apricot
Assert.Same(seq.FirstCyclic, seq.CurrAnim);
}
}