feat(R1-P5): AnimationSequencer rehosted on the verbatim CSequence core

The adapter keeps its full public surface (every consumer compiles
unchanged) while the internals move to Motion.CSequence:

DELETED (legacy invented mechanisms, per the R1 gap map + Phase R
mandate): the AnimNode class + adapter queue/_currNode/_firstCyclic/
_framePosition, the ACE-fabricated FrameEpsilon boundary math, the
safety=64 advance cap, ClearCyclicTail surgery, the stale-head
_currNode relocation block, per-node IsLooping/Velocity/Omega, the
adapter-side AdvanceToNextAnimation/ApplyPosFrame/ExecuteHooks, the
per-node !IsLooping AnimationDone gate (now the core's list-structure
head != first_cyclic gate, G5).

REHOSTED: SetCycle rebuild = RemoveCyclicAnims (+ClearAnimations for
K-fix18) + retail add_motion appends (framerate-only AnimData scaling,
G10 loop membership: the LAST appended node is the cyclic tail) + Fix B
via RemoveAllLinkAnimations (the core's snap-forward IS Fix B's
behavior); Advance = core.Update(dt, null) with hooks flowing through
an IAnimHookQueue adapter into the existing ConsumePendingHooks drain;
fast path = core.MultiplyCyclicAnimationFramerate + the adapter-level
velocity rescale (R2's change_cycle_speed composite stand-in, G13).
Kept byte-identical: adjust_motion remap, GetLink + stop-anim fallback,
K-fix18, velocity synthesis, SlerpRetailClient, the #61 render-side
clamp, SCFAST/SCFULL diagnostics.

Tests: 2 updated with decomp citations — the reverse-start boundary now
pins retail's bare-int HighFrame+1 (0x00525c80, no epsilon, G1); the
root-motion accumulation test pins the inert-stub state pending P6's
Frame wiring (G7). All 44 sequencer tests + the 56 core tests + full
suite green (3346).

Implemented by a dedicated agent against the P5 spec; diff + tests
reviewed, suite re-verified before commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 20:20:58 +02:00
parent 658b91d8aa
commit 9147344a6f
2 changed files with 398 additions and 693 deletions

File diff suppressed because it is too large Load diff

View file

@ -614,12 +614,19 @@ public sealed class AnimationSequencerTests
// CurrentMotion should record the original TurnLeft command.
Assert.Equal(TurnLeft, seq.CurrentMotion);
// Without swap: StartFrame=0, EndFrame=3 (original range preserved).
// GetStartFramePosition for negative speed = (EndFrame+1)-eps = (3+1)-eps ≈ 3.99999.
// The cursor starts near the HIGH end and counts DOWN toward StartFrame(=0).
// R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated
// epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's
// AnimSequenceNode.GetStartingFrame (0x00525c80) returns a BARE INT
// for reverse playback: HighFrame + 1 (gap map G1 — "NO epsilon").
// LowFrame=0, HighFrame=3 (no swap at append time; the swap only
// happens inside MultiplyFramerate for an in-place resign, which
// this path doesn't take), so the retail-exact start position is
// exactly 4.0, not "near but under" 4.0. The cursor starts at the
// boundary and counts DOWN toward LowFrame(=0) on the next Advance.
double pos = GetFramePosition(seq);
Assert.True(pos > 3.9 && pos < 4.0,
$"Expected framePosition near 3.99999 (reverse start near EndFrame+1) but got {pos}");
Assert.True(pos == 4.0,
$"Expected framePosition == 4 (bare-int reverse start = HighFrame+1, "
+ $"retail AnimSequenceNode.GetStartingFrame 0x00525c80 has NO epsilon — G1); got {pos}");
}
[Fact]
@ -990,13 +997,29 @@ public sealed class AnimationSequencerTests
Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u);
}
// ── PosFrames root motion (Phase E.1) ────────────────────────────────────
// ── PosFrames root motion (Phase E.1; retired R1-P5) ─────────────────────
[Fact]
public void Advance_WithPosFrames_AccumulatesRootMotion()
public void ConsumeRootMotionDelta_AlwaysZero_AccumulatorUnwiredPendingP6()
{
// Animation with PosFrames flag and per-frame origin deltas should
// surface a non-zero root motion delta after Advance.
// R1-P5 (2026-07-02): pre-cutover, Advance's own hand-rolled
// per-frame-crossing loop wrote _rootMotionPos/_rootMotionRot
// directly from Animation.PosFrames (AFrame.Combine/Subtract). That
// loop is DELETED — frame advance now runs entirely inside
// CSequence.Update, and root motion in retail flows through
// CSequence.apply_physics(Frame*, ...) (0x00524ab0), which needs an
// actual target Frame passed into Update. This adapter's Advance
// passes null (gap map G7: "R1-P6 wires it"), so PosFrames deltas
// are computed by the core's UpdateInternal but have nowhere to
// land — ConsumeRootMotionDelta has zero live writers post-cutover
// (confirmed: also zero external callers pre-cutover, per the R1
// gap map API-migration table).
//
// This test pins that ConsumeRootMotionDelta is a safe, inert stub
// until P6 wires a real Frame through Advance/Update — NOT that
// root motion doesn't work. When P6 lands, this test should be
// replaced with one asserting real accumulation through the wired
// Frame path.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000110u;
@ -1022,17 +1045,13 @@ public sealed class AnimationSequencerTests
seq.SetCycle(Style, Motion);
seq.ConsumeRootMotionDelta(); // clear
// Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied.
// Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2). Pre-cutover
// this would have accumulated ~2.0 on X; post-cutover it's inert.
seq.Advance(0.25f);
var (pos, _) = seq.ConsumeRootMotionDelta();
var (pos, rot) = seq.ConsumeRootMotionDelta();
// Each crossing adds +X origin → total X should be 2.
Assert.True(pos.X >= 1.8f && pos.X <= 2.2f,
$"Expected ~2.0 root motion X after 2 crossings, got {pos.X}");
// A subsequent consume with no advance should return zero (drained).
var (pos2, _) = seq.ConsumeRootMotionDelta();
Assert.Equal(Vector3.Zero, pos2);
Assert.Equal(Vector3.Zero, pos);
Assert.Equal(Quaternion.Identity, rot);
}
[Fact]
@ -1624,14 +1643,27 @@ public sealed class AnimationSequencerTests
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>
/// <summary>
/// Expose the core CSequence's FrameNumber via reflection (test-only).
/// R1-P5 rehost (2026-07-02): _framePosition lived directly on
/// AnimationSequencer pre-cutover; it now lives on the private _core
/// (CSequence) field as the public FrameNumber. Two-hop reflection:
/// grab _core, then its FrameNumber field.
/// </summary>
private static double GetFramePosition(AnimationSequencer seq)
{
var field = typeof(AnimationSequencer)
.GetField("_framePosition",
var coreField = typeof(AnimationSequencer)
.GetField("_core",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
return field is null ? -1.0 : (double)field.GetValue(seq)!;
var core = coreField?.GetValue(seq);
if (core is null) return -1.0;
var frameNumberField = core.GetType()
.GetField("FrameNumber",
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.Instance);
return frameNumberField is null ? -1.0 : (double)frameNumberField.GetValue(core)!;
}
/// <summary>