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>
269 lines
11 KiB
C#
269 lines
11 KiB
C#
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);
|
||
}
|
||
}
|