From 658b91d8aadabc49931925c1bb326cca7eac0ec5 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 20:05:04 +0200 Subject: [PATCH] feat(R1-P4): verbatim update_internal / update / advance_to_next_animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.Core/Physics/Motion/CSequence.cs | 225 +++++++++++++++ src/AcDream.Core/Physics/Motion/FrameOps.cs | 24 ++ .../Physics/Motion/CSequenceUpdateTests.cs | 269 ++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs diff --git a/src/AcDream.Core/Physics/Motion/CSequence.cs b/src/AcDream.Core/Physics/Motion/CSequence.cs index 06689e0b..a9da00b8 100644 --- a/src/AcDream.Core/Physics/Motion/CSequence.cs +++ b/src/AcDream.Core/Physics/Motion/CSequence.cs @@ -6,6 +6,26 @@ using DatReaderWriter.Types; namespace AcDream.Core.Physics.Motion; +/// +/// R1-P4 host seam standing in for retail's CPhysicsObj.anim_hooks +/// SmartArray + the global AnimDoneHook singleton +/// (add_anim_hook 0x00514c20; process_hooks 0x00511550 drains +/// once per physics tick — the drain point stays with the host until R6 +/// places it per retail's UpdateObjectInternal order). +/// +public interface IAnimHookQueue +{ + /// Queue a matched AnimFrame hook (already direction-filtered + /// by execute_hooks). + void AddAnimHook(DatReaderWriter.Types.AnimationHook hook); + + /// Queue the global animation-done hook — retail's + /// AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone → + /// CPartArray::AnimationDone(1) chain (R2 consumes it as + /// MotionDone). + void AddAnimDoneHook(); +} + /// /// R1-P2 — verbatim port of retail's CSequence 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 ─────────────────────────────────── + + /// Host hook queue (hook_obj); null = hooks dropped + /// (objects without a physics host). + public IAnimHookQueue? HookObj; + + /// + /// CSequence::update (0x00525b80, §22): non-empty list → + /// then ; empty list + /// with a Frame → accumulated-physics free motion. + /// + 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); + } + } + + /// + /// CSequence::update_internal (0x005255d0, §21 — ACE-verified + /// skeleton, P0-pins.md): advance 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). + /// + 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 + } + } + + /// + /// CSequence::advance_to_next_animation (0x005252b0, §23): four + /// pose operations per transition — un-apply the outgoing node's pose + /// (subtract1 + residual physics), step (forward wraps to + /// first_cyclic; REVERSE wraps to the LIST TAIL — asymmetric by + /// design), reseed 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). + /// + 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); + } + } + + /// + /// CSequence::execute_hooks (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). + /// + 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? CurrAnimNode diff --git a/src/AcDream.Core/Physics/Motion/FrameOps.cs b/src/AcDream.Core/Physics/Motion/FrameOps.cs index 650d90b3..a36a1292 100644 --- a/src/AcDream.Core/Physics/Motion/FrameOps.cs +++ b/src/AcDream.Core/Physics/Motion/FrameOps.cs @@ -55,4 +55,28 @@ public static class FrameOps /// global through the orientation, then . public static void Rotate(Frame frame, Vector3 rotationLocal) => GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation)); + + /// + /// Frame::combine as used by the CSequence pose path (ACE + /// AFrame.Combine, verified against the pre-R1 acdream port): + /// apply the pose — origin += rotate(pos.Origin by orientation), then + /// orientation ∘= pos.Orientation. + /// + public static void Combine(Frame frame, Frame pos) + { + frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation); + frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation); + } + + /// + /// Frame::subtract1 — un-apply the pose: orientation ∘= + /// conj(pos.Orientation) FIRST, then origin −= rotate(pos.Origin by the + /// UPDATED orientation) (inverse order of ). + /// + 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); + } } diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs new file mode 100644 index 00000000..4b486bb1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs @@ -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; + +/// +/// R1-P4 — the frame-advance core: update_internal (0x005255d0), +/// update (0x00525b80), advance_to_next_animation +/// (0x005252b0), execute_hooks (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. +/// +public class CSequenceUpdateTests +{ + private sealed class MapLoader : IAnimationLoader + { + private readonly Dictionary _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 Events = new(); + public void AddAnimHook(AnimationHook hook) + => Events.Add($"hook:{((TaggedHook)hook).Tag}:{hook.Direction}"); + public void AddAnimDoneHook() => Events.Add("ANIMDONE"); + } + + /// Hook subclass carrying a test tag (frame index). + private sealed class TaggedHook : AnimationHook + { + public string Tag = ""; + public override AnimationHookType HookType => (AnimationHookType)0; + } + + /// numFrames frames; one Forward-direction tagged hook per frame. + 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 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); + } +}