acdream/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.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

269 lines
11 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.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);
}
}