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:
parent
5138b8fb01
commit
658b91d8aa
3 changed files with 518 additions and 0 deletions
|
|
@ -6,6 +6,26 @@ using DatReaderWriter.Types;
|
|||
|
||||
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>
|
||||
/// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list
|
||||
/// surgery (Phase R plan stage R1; oracle
|
||||
|
|
@ -273,6 +293,211 @@ public sealed class CSequence
|
|||
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 LinkedListNode<AnimSequenceNode>? CurrAnimNode
|
||||
|
|
|
|||
|
|
@ -55,4 +55,28 @@ public static class FrameOps
|
|||
/// global through the orientation, then <see cref="GRotate"/>.</summary>
|
||||
public static void Rotate(Frame frame, Vector3 rotationLocal)
|
||||
=> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
269
tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs
Normal file
269
tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue