diff --git a/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md b/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md index b267dce0..bb2fa75c 100644 --- a/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md +++ b/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md @@ -1140,6 +1140,16 @@ still moves the frame even with nothing animating. ## 23. `CSequence::advance_to_next_animation` (line 301622, addr `0x005252b0`) +> **2026-07-19 assembly correction:** the cleaned pseudocode below lost four +> branch predicates. Matching v11.4186 assembly and ACE agree: elapsed ≥ 0 +> subtracts the outgoing pose only for negative framerate and combines the +> incoming pose only for positive framerate; elapsed < 0 subtracts only for +> non-negative outgoing framerate and combines only for negative incoming +> framerate. The nested physics gate is strict +> `abs(framerate) > F_EPSILON`. Use +> `r1-ace-sequence.md:70-86` and the production `CSequence` conformance tests, +> not the unconditional-pose cleanup later in this section. + **Signature:** `advance_to_next_animation(CSequence const* this, double arg2, AnimSequenceNode const** arg3, double* arg4, Frame* arg5)` — `arg2` is the signed elapsed/rate value carried over from `update_internal` diff --git a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md index 5a4e7d0e..cd73d4c0 100644 --- a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md +++ b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md @@ -23,13 +23,11 @@ Current code: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines) + `get_high_frame()` (fwd) / `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover + `animDone=true` → per-frame crossing loop → `if (!animDone) return` → AnimDone gate (`head != first_cyclic`) → `advance_to_next_animation` → carry leftover → loop. -- **ONE remaining unresolved decomp ambiguity** — raw decomp at `0x0052598a-0x0052598d` appears to - zero `arg2` (elapsed) after `advance_to_next_animation` before looping, while ACE carries - `timeElapsed = frameTimeElapsed` (the leftover). If retail truly zeroed it, a lag spike could - never fast-forward through multiple queued nodes in one tick (observable). ACE's reading is far - more plausible (BN likely lost the var reassignment through the x87 stack slot). **Pin in the R1 - pseudocode commit (P0)** — cdb breakpoint on `CSequence::advance_to_next_animation` counting - invocations per `CSequence::update` call under induced multi-node overshoot. +- **Leftover carry RESOLVED from matching v11.4186 assembly (2026-07-19).** + `0x00525982-0x0052598d` copies the local `frameTimeElapsed` back into the + elapsed argument before `0x00525990` loops to the update head. Retail carries + leftover time across animation nodes exactly as ACE does; the apparent zero + in the pseudo-C was x87 stack noise. Severity key: **BLOCKER** = must be right or R1's conformance harness is meaningless; **HIGH** = visible animation wrongness / blocks R2+; **MED** = edge-case visible or blocks a later @@ -49,7 +47,7 @@ stage; **LOW** = dormant/textual. | G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); `CPhysicsObj::process_hooks` executes them inside `UpdatePositionInternal`, before the transition and manager tail, then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar. R6 processes semantic `AnimationDone` at capture time while retaining pose-dependent presentation delivery until current root/part poses are published. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18); corrected order pinned in `2026-07-19-r6-update-object-order-pseudocode.md` | `AnimationSequencer.cs` pending-hook queue; `AnimationHookFrameQueue.Capture` semantic completion and `Drain` presentation delivery | RETIRED by R6 semantic-order cutover 2026-07-19 | | G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it | | G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED | -| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete | +| G9 | **`advance_to_next_animation` uses elapsed-direction plus node-framerate sign gates.** For elapsed ≥ 0, subtract the outgoing pose only when its framerate is negative, step Next/wrap `first_cyclic`, and combine the incoming pose only when its framerate is positive. For elapsed < 0, subtract when outgoing framerate is non-negative, step Previous/wrap list tail, and combine when incoming framerate is negative. Physics inside a selected pose operation uses the separate strict `abs(framerate) > F_EPSILON` gate. Matching v11.4186 assembly and ACE agree; the earlier “four unconditional pose ops” cleanup was wrong. | `0x005252B0`; matching assembly audit 2026-07-19; `r1-ace-sequence.md:70-86` | `CSequence.AdvanceToNextAnimation` + exact-boundary/sign conformance tests | **RETIRED 2026-07-19** | | G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct | | G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms | | G12 | **`combine_physics`/`subtract_physics` accumulators absent.** Retail `velocity += / -=` element-wise (x87-widened). acdream only has replace (`EnqueueMotionData`) + `ClearPhysics`. Needed by R2's fast path (`change_cycle_speed` + `subtract_motion(old)` + `combine_motion(new)`) and by jump/knockback physics later. | `0x005248c0`/`0x00524900` (lines 300818/300832, §12/13) | no equivalent in `AnimationSequencer.cs` | MED (trivial; part of the verbatim class) | @@ -80,7 +78,7 @@ divergence-register row when touched. | Queue-then-drain hook model (hooks NOT executed inline during frame advance) | `add_anim_hook 0x00514c20` + `process_hooks 0x00511550` | `_pendingHooks` + `ConsumePendingHooks()` (drain placement moves in R6, mechanism correct) | | Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) | | Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` | -| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | ACE `Sequence.cs:436-442` (`timeElapsed = frameTimeElapsed` + recurse); decomp loop-back @0x005255e8 (see open ambiguity above) | `Advance`'s `timeRemaining`/overflow continue (r1-acdream map row "update_internal") | +| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | Matching v11.4186 assembly `0x00525982-0x00525990` copies `frameTimeElapsed` back then loops; ACE `Sequence.cs:436-442` agrees | `CSequence.UpdateInternal` assigns `timeElapsed = frameTimeElapsed` before looping | | Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) | | `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) | | `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") | @@ -98,10 +96,10 @@ New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Naming: retai (`CSequence`, `AnimSequenceNode`) or thin C# equivalents — decided in P1, consistent after. Every commit: build+test green; register rows added/retired in-commit. -**P0 — pseudocode + ambiguity pinning (docs only).** +**P0 — pseudocode + ambiguity pinning (docs only; leftover resolved 2026-07-19).** Write `docs/research/2026-07-xx-csequence-pseudocode.md` from r1-csequence-decomp.md, -CORRECTING §21 to the ACE-verified skeleton (this doc's header), and pin the ONE open ambiguity: -leftover-time carry vs `arg2=0` at `0x0052598a`. +CORRECTING §21 to the ACE-verified skeleton (this doc's header). The former +leftover-time ambiguity is resolved by matching assembly: carry is authoritative. Fixture source: **cdb trace** — breakpoint `acclient!CSequence::advance_to_next_animation` + `acclient!CSequence::update` with hit counters (pattern: `tools/cdb/l2g-observer.cdb`); ratio >1 advance-per-update under a stall/lag proves the carry. Also capture @@ -151,7 +149,8 @@ to `get_high_frame()`/`get_low_frame()` + `frameTimeElapsed` leftover + animDone crossing loop (combine/subtract pos_frame if `pos_frames != null`; `apply_physics(1/framerate, elapsed)` if `|framerate| ≥ 0.000199999995f`; `execute_hooks(part_frame, +1/-1)`) → `if (!animDone) return` → AnimDone gate `head != first_cyclic` → queue global AnimDoneHook → -`advance_to_next_animation` (four pose ops; fwd wrap first_cyclic, rev wrap TAIL) → carry leftover +`advance_to_next_animation` (elapsed-direction/framerate-sign-gated pose ops; +positive elapsed wraps first_cyclic, negative elapsed wraps TAIL) → carry leftover (per P0's pin) → loop (iterative, not ACE's recursion). `update`: non-empty → internal+`apricot`; empty → `apply_physics(frame, elapsed, elapsed)`. `execute_hooks` queues into an `IAnimHookQueue` host seam (stands in for `CPhysicsObj.anim_hooks`; GameWindow drain point diff --git a/src/AcDream.Core/Physics/Motion/CSequence.cs b/src/AcDream.Core/Physics/Motion/CSequence.cs index a9da00b8..b9055182 100644 --- a/src/AcDream.Core/Physics/Motion/CSequence.cs +++ b/src/AcDream.Core/Physics/Motion/CSequence.cs @@ -424,14 +424,13 @@ public sealed class CSequence /// /// 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). + /// sign-gated pose operations per transition. Positive elapsed time + /// removes only a reverse-playing outgoing pose, steps next (wrapping to + /// first_cyclic), and applies only a forward-playing incoming pose. + /// Negative elapsed time mirrors that over previous/list-tail. The + /// framerate sign gates are present in matching v11.4186 assembly and in + /// ACE's line-for-line port; physics has a separate strict + /// abs(framerate) > F_EPSILON gate. /// public void AdvanceToNextAnimation(double timeElapsed, Frame? frame) { @@ -440,39 +439,57 @@ public sealed class CSequence 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) { + if (frame is not null && outgoing.Framerate < 0f) + { + var pos = outgoing.GetPosFrame((int)FrameNumber); + if (pos is not null) + FrameOps.Subtract1(frame, pos); + if (Math.Abs(outgoing.Framerate) > FrameOps.FEpsilon) + ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed); + } + _currAnim = _currAnim.Next ?? _firstCyclic; if (_currAnim is null) return; FrameNumber = _currAnim.Value.GetStartingFrame(); + + var incoming = _currAnim.Value; + if (frame is not null && incoming.Framerate > 0f) + { + var pos = incoming.GetPosFrame((int)FrameNumber); + if (pos is not null) + FrameOps.Combine(frame, pos); + if (Math.Abs(incoming.Framerate) > FrameOps.FEpsilon) + ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed); + } } else { + if (frame is not null && outgoing.Framerate >= 0f) + { + var pos = outgoing.GetPosFrame((int)FrameNumber); + if (pos is not null) + FrameOps.Subtract1(frame, pos); + if (Math.Abs(outgoing.Framerate) > FrameOps.FEpsilon) + ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed); + } + _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); + var incoming = _currAnim.Value; + if (frame is not null && incoming.Framerate < 0f) + { + var pos = incoming.GetPosFrame((int)FrameNumber); + if (pos is not null) + FrameOps.Combine(frame, pos); + if (Math.Abs(incoming.Framerate) > FrameOps.FEpsilon) + ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed); + } } } diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs index 4b486bb1..5d09e661 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs @@ -17,8 +17,8 @@ namespace AcDream.Core.Tests.Physics.Motion; /// 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) → +/// (list HEAD != first_cyclic) → advance (sign-gated pose transition, +/// forward wraps to first_cyclic, negative elapsed wraps to the LIST TAIL) → /// carry the leftover (P0 pin) → loop. NO safety cap, NO boundary /// epsilon. /// @@ -68,6 +68,24 @@ public class CSequenceUpdateTests return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate }; } + private static Animation MakePoseAnim(float poseX) + { + var anim = new Animation(); + var part = new AnimationFrame(1u); + part.Frames.Add(new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + anim.PartFrames.Add(part); + anim.PosFrames.Add(new Frame + { + Origin = new Vector3(poseX, 0f, 0f), + Orientation = Quaternion.Identity, + }); + return anim; + } + private static (CSequence seq, RecordingHookQueue hooks) Cyclic(int frames, float framerate = 30f) { var loader = new MapLoader(); @@ -195,6 +213,75 @@ public class CSequenceUpdateTests Assert.Equal(3, seq.CurrAnim!.HighFrame); // B (4 frames → high 3) } + [Fact] + public void AdvanceToNextAnimation_PositiveElapsed_SkipsPositiveOutgoingPose() + { + var loader = new MapLoader(); + loader.Add(1u, MakePoseAnim(1f)); + loader.Add(2u, MakePoseAnim(2f)); + var seq = new CSequence(loader); + seq.AppendAnimation(Ad(1u, framerate: 30f, high: 0)); + seq.AppendAnimation(Ad(2u, framerate: 30f, high: 0)); + seq.FrameNumber = 0; + var frame = new Frame + { + Origin = new Vector3(10f, 0f, 0f), + Orientation = Quaternion.Identity, + }; + + seq.AdvanceToNextAnimation(timeElapsed: 0.1, frame); + + // Retail's positive-elapsed branch removes the outgoing pose only + // when that node plays backward. The positive incoming pose applies. + Assert.Equal(12f, frame.Origin.X, 5); + } + + [Fact] + public void AdvanceToNextAnimation_NegativeElapsed_SkipsNegativeOutgoingPose() + { + var loader = new MapLoader(); + loader.Add(1u, MakePoseAnim(1f)); + loader.Add(2u, MakePoseAnim(2f)); + var seq = new CSequence(loader); + seq.AppendAnimation(Ad(1u, framerate: -30f, high: 0)); + seq.AppendAnimation(Ad(2u, framerate: -30f, high: 0)); + seq.FrameNumber = 0; + var frame = new Frame + { + Origin = new Vector3(10f, 0f, 0f), + Orientation = Quaternion.Identity, + }; + + seq.AdvanceToNextAnimation(timeElapsed: -0.1, frame); + + // Retail's negative-elapsed branch removes only a non-negative + // outgoing node, then applies the negative incoming node. + Assert.Equal(12f, frame.Origin.X, 5); + } + + [Fact] + public void AdvanceToNextAnimation_ExactEpsilon_AppliesPoseButNotPhysics() + { + var loader = new MapLoader(); + loader.Add(1u, MakePoseAnim(1f)); + loader.Add(2u, MakePoseAnim(2f)); + var seq = new CSequence(loader); + seq.AppendAnimation(Ad(1u, framerate: 30f, high: 0)); + seq.AppendAnimation(Ad(2u, framerate: FrameOps.FEpsilon, high: 0)); + seq.SetVelocity(Vector3.UnitX); + var frame = new Frame + { + Origin = new Vector3(10f, 0f, 0f), + Orientation = Quaternion.Identity, + }; + + seq.AdvanceToNextAnimation(timeElapsed: 0.1, frame); + + // The sign gate owns pose application. Physics has the separate, + // strict abs(framerate) > F_EPSILON boundary. + Assert.Equal(12f, frame.Origin.X, 5); + } + // ── stationary / degenerate (G8 + else-branch) ────────────────────── [Fact]