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>
This commit is contained in:
Erik 2026-07-19 21:44:12 +02:00
parent c5ab99081c
commit 31a0889f08
4 changed files with 155 additions and 42 deletions

View file

@ -17,8 +17,8 @@ namespace AcDream.Core.Tests.Physics.Motion;
/// 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) →
/// (list HEAD != first_cyclic) → advance (sign-gated pose transition,
/// forward wraps to first_cyclic, negative elapsed wraps to the LIST TAIL) →
/// carry the leftover (P0 pin) → loop. NO safety cap, NO boundary
/// epsilon.
/// </summary>
@ -68,6 +68,24 @@ public class CSequenceUpdateTests
return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate };
}
private static Animation MakePoseAnim(float poseX)
{
var anim = new Animation();
var part = new AnimationFrame(1u);
part.Frames.Add(new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
});
anim.PartFrames.Add(part);
anim.PosFrames.Add(new Frame
{
Origin = new Vector3(poseX, 0f, 0f),
Orientation = Quaternion.Identity,
});
return anim;
}
private static (CSequence seq, RecordingHookQueue hooks) Cyclic(int frames, float framerate = 30f)
{
var loader = new MapLoader();
@ -195,6 +213,75 @@ public class CSequenceUpdateTests
Assert.Equal(3, seq.CurrAnim!.HighFrame); // B (4 frames → high 3)
}
[Fact]
public void AdvanceToNextAnimation_PositiveElapsed_SkipsPositiveOutgoingPose()
{
var loader = new MapLoader();
loader.Add(1u, MakePoseAnim(1f));
loader.Add(2u, MakePoseAnim(2f));
var seq = new CSequence(loader);
seq.AppendAnimation(Ad(1u, framerate: 30f, high: 0));
seq.AppendAnimation(Ad(2u, framerate: 30f, high: 0));
seq.FrameNumber = 0;
var frame = new Frame
{
Origin = new Vector3(10f, 0f, 0f),
Orientation = Quaternion.Identity,
};
seq.AdvanceToNextAnimation(timeElapsed: 0.1, frame);
// Retail's positive-elapsed branch removes the outgoing pose only
// when that node plays backward. The positive incoming pose applies.
Assert.Equal(12f, frame.Origin.X, 5);
}
[Fact]
public void AdvanceToNextAnimation_NegativeElapsed_SkipsNegativeOutgoingPose()
{
var loader = new MapLoader();
loader.Add(1u, MakePoseAnim(1f));
loader.Add(2u, MakePoseAnim(2f));
var seq = new CSequence(loader);
seq.AppendAnimation(Ad(1u, framerate: -30f, high: 0));
seq.AppendAnimation(Ad(2u, framerate: -30f, high: 0));
seq.FrameNumber = 0;
var frame = new Frame
{
Origin = new Vector3(10f, 0f, 0f),
Orientation = Quaternion.Identity,
};
seq.AdvanceToNextAnimation(timeElapsed: -0.1, frame);
// Retail's negative-elapsed branch removes only a non-negative
// outgoing node, then applies the negative incoming node.
Assert.Equal(12f, frame.Origin.X, 5);
}
[Fact]
public void AdvanceToNextAnimation_ExactEpsilon_AppliesPoseButNotPhysics()
{
var loader = new MapLoader();
loader.Add(1u, MakePoseAnim(1f));
loader.Add(2u, MakePoseAnim(2f));
var seq = new CSequence(loader);
seq.AppendAnimation(Ad(1u, framerate: 30f, high: 0));
seq.AppendAnimation(Ad(2u, framerate: FrameOps.FEpsilon, high: 0));
seq.SetVelocity(Vector3.UnitX);
var frame = new Frame
{
Origin = new Vector3(10f, 0f, 0f),
Orientation = Quaternion.Identity,
};
seq.AdvanceToNextAnimation(timeElapsed: 0.1, frame);
// The sign gate owns pose application. Physics has the separate,
// strict abs(framerate) > F_EPSILON boundary.
Assert.Equal(12f, frame.Origin.X, 5);
}
// ── stationary / degenerate (G8 + else-branch) ──────────────────────
[Fact]