fix(animation): restore retail transition sign gates

Match the v11.4186 CSequence transition assembly and ACE cross-reference, including direction-specific pose gates and the strict physics epsilon boundary. Add conformance tests and correct the stale research interpretation.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-19 21:44:12 +02:00
parent c5ab99081c
commit 31a0889f08
4 changed files with 155 additions and 42 deletions

View file

@ -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`

View file

@ -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