Visual-verify of fix#2 (commit 863d96b) showed [SCFULL] correctly reports
currNodeIsCyclic=True after each direct Walk↔Run SetCycle (the link is
removed and _currNode is set to _firstCyclic). User report still:
- Animation continues running visually after Shift toggle to Walk
- Body slows ("speed decreases"), causing rubber-banding
- Adding a turn motion in that state makes the cycle finally transition
to walking
So either:
- _currNode is reset to a stale node BETWEEN SetCycle and Advance
- _currNode is correctly on the new cycle but its AnimRef is wrong
(e.g., the same Animation as the previous cycle, dat-side issue)
- BuildBlendedFrame reads from somewhere other than _currNode
Adds CurrentNodeDiag + FirstCyclicAnimRefHash properties on
AnimationSequencer that expose the active node's Animation
identity-hash, IsLooping, Framerate, frame range, and FramePosition.
TickAnimations logs them on every SEQSTATE tick (1 Hz throttle, gated
on ACDREAM_REMOTE_VEL_DIAG=1).
The [CURRNODE] line with animRef vs firstCyclicAnimRef proves whether
_currNode is actually on the new cycle's anim or has drifted to
something else. Compared across SetCycle SCFULL log lines + the
following CURRNODE ticks, we can see the exact moment the cycle
diverges from what SetCycle set.
No code-behavior changes. Pure read-only instrumentation. Per
Phase 4.5 of systematic-debugging — STOP attempting fixes; gather
evidence first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause identified by research-agent read of AnimationSequencer.SetCycle
+ Advance + the per-tick TickAnimations call site:
- SetCycle enqueues transition link + new cycle, then forces _currNode
onto firstNew (the LINK), per the 357dcc0 fix that pinned _currNode
to the most-recently-enqueued node.
- Advance plays the link to completion (~100–300 ms at Framerate 30 ×
link runSpeed) before AdvanceToNextAnimation moves _currNode forward
to the cycle.
- For Walk↔Run direct toggles faster than the link's drain time, the
next UM arrives, SetCycle restarts _currNode on a fresh link, and
the cycle node at the queue tail is never reached.
- BuildBlendedFrame returns frames from the link the entire time —
user observes the link's interpolation pose ("blips forward in
walking animation"), never the new Walk or Run cycle.
Confirmed by [SCFULL] currNodeIsCyclic=False after every direct
Walk↔Run transition in launch-39-candidate.log.
Fix: when prev motion AND new motion are both locomotion cycles
(WalkForward, WalkBackward, RunForward, SideStep L/R), land
_currNode on _firstCyclic (the new cycle node) instead of firstNew
(the link), and remove the just-enqueued link from the queue.
Conditional on BOTH being locomotion to avoid regressing cases that
DO need the link to play:
- Idle→Run (link is the wind-up pose)
- Falling→Ready (landing animation)
- Ready→Sitting/Crouching/Sleeping
- Combat substates (attack/parry/ready transitions)
Reverted commit c06b6c5 demonstrated that unconditional link skip
breaks all of those — this fix is narrower.
Retail reference: cdb live trace 2026-05-03 of a Walk→Run direct
transition logged add_to_queue(45000005) followed by
add_to_queue(44000007) with truncate_animation_list never firing —
matching the observed semantics this fix implements.
42/42 AnimationSequencer tests pass. The 8 pre-existing test
failures elsewhere on the branch (BSPStepUp, MotionInterpreter
WalkBackward, etc.) are unrelated to this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-session cleanup of the 2026-05-03 remote-motion debug session.
Documentation:
- CLAUDE.md: add ⚠️ DO-NOT-ENABLE warning for ACDREAM_INTERP_MANAGER=1
in the diagnostic env-vars list. Add an "Outbound motion wire format"
section documenting acdream's WalkForward+HoldKey.Run encoding (which
ACE auto-upgrades to RunForward on relay) so future sessions don't
re-derive it.
- docs/ISSUES.md: file two new issues:
* #39 — Run↔Walk cycle transition not visible on observed
retail-driven player remotes when watched from acdream. Root cause
located: ApplyServerControlledVelocityCycle is gated by
IsPlayerGuid, excluding the exact case where ACE doesn't broadcast
a UM (shift toggle while direction key held). Fix sketch ~10
lines, separate commit. Cross-references the four-agent
investigation prompt.
* #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed. Documents
why (e94e791 conflated MoveOrTeleport with update_object), the
visible symptoms (staircase Z, position blips), and why
Commit B (039149a)'s ResolveWithTransition port was insufficient
(env-var path also clears body.Velocity → no horizontal Euler
motion → sweep input is queue catch-up only, which stair-steps).
Fix path = separate L.3 follow-up to re-integrate PositionManager
additively on top of the legacy chain.
Code:
- GameWindow.cs:6042: prepend a ⚠️ REGRESSED warning block at the top
of the env-var per-frame branch so anyone reading the code is
immediately aware not to enable it. Cross-references ISSUES.md #40.
- AnimationSequencer.cs: re-throttle [SCFAST]/[SCFULL] diagnostics to
0.5s per instance (rolled back from A.1's unthrottled experiment).
Already served its purpose; throttled is enough for steady-state
diagnostics. Restore _lastSetCycleDiagTime field.
No behavior change for any current launch (env-var unset = legacy
path unchanged). Build green; baseline test failures (8) unchanged
from prior commit, none introduced by this session.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit A's log refuted H2 (UPCYCLE never fires for player guids — gated
by IsPlayerGuid), H4 (SCNULLFALLBACK count = 0), H5 (PartTemplate
counts always match anim PartFrames). The remaining puzzle:
SCFULL Ready=23 dominates (all motions: 41 total)
SETCYCLE picker logged: only 9 transitions to Ready
Difference (≥14 extra Ready full-rebuilds) suggests a non-picker source,
OR many UMs arriving with no ForwardCommand bit being routed through
the picker's `else if (!command.HasValue) { fullMotion = Ready; }` at
GameWindow.cs:2671-2673, knocking the cycle back to Ready mid-Walk/Run.
This commit removes the 0.5s throttle on SCFAST and SCFULL (every call
now logs) and adds [UM_RAW] at OnLiveMotionUpdated entry to print:
- stance / fwd / fwdSpd / side / turn / movementType / isMoveTo
- sequencer.CurrentMotion at call time
per UM, gated on ACDREAM_REMOTE_VEL_DIAG=1.
Combined: one repro pass tells us (a) UM arrival rate per remote, (b)
which UMs lack ForwardCommand, (c) whether the picker is the source of
the 14+ extra Ready calls. Commit B is then a one-line fix.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds five diagnostics, no behavior changes. All gated on existing
ACDREAM_REMOTE_VEL_DIAG=1 env var. Plan at
~/.claude/plans/yes-make-a-plan-parsed-axolotl.md.
Five hypotheses surviving from the four-agent investigation
(docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md):
H1 SEQSTATE silently swallowed by OMEGA_DIAG sharing throttle clock
H2 ApplyServerControlledVelocityCycle races UM-driven SetCycle per UP
H3 SetCycle fast-path returns without updating _currNode
H4 GetLink/GetCycle null → defensive fallback lands on stale head
H5 PartTemplate.Count diverges from anim PartFrames.Count → silent
identity-quat freeze
Diagnostics added (all log lines are grep-prefixed):
D1 Split LastSeqStateLogTime field for SEQSTATE — own throttle.
Foundational: every other diag depends on SEQSTATE telling truth.
D2 [UPCYCLE] inside ApplyServerControlledVelocityCycle, +
[UPCYCLE_SRC] at the call site (wire vs synth velocity).
D3 [SCFAST] in fast-path return, [SCFULL] at full-rebuild end.
D4 [SCNULLFALLBACK] in the null-data defensive fallback.
D5 [PARTSDIAG] with pt.Count / seqFrames.Count / setup.Parts.Count /
anim.PartFrames[0].Frames.Count + sum-of-components hash.
Repro recipe:
$env:ACDREAM_INTERP_MANAGER = "1"
$env:ACDREAM_REMOTE_VEL_DIAG = "1"
dotnet run … 2>&1 | Tee-Object tools/diag-logs/walkrun-<ts>.log
Then watch a retail-driven character through acdream and exercise:
idle → W run → release → shift+W walk → release → demote → promote →
run+turn (this last one is the H1 trap).
Decision matrix in the plan file maps each [TAG] signature to a
specific Commit B fix.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skip SubState commands in UM Commands list iteration
Two related fixes for the "remote-driven character animation cycle
does not visibly switch" bug:
1. AnimationSequencer.SetCycle now snapshots _queue.Last BEFORE
appending the new link/cycle nodes, then forces _currNode onto
preEnqueueTail.Next (= first newly-added node). Without this,
_currNode could stay pointing into stale non-cyclic head frames
left over from the previous cycle (typically a Walk_link or
Ready_link's tail), and the visible animation continues playing
those stale frames before the queue advances naturally to the
new cycle. Local player avoided the bug because
PlayerMovementController fires SetCycle in a tight per-input loop
that keeps the queue clean; remote player accumulates stale
link drains across many bundled UMs.
2. OnLiveMotionUpdated's UM Commands list iteration now skips
SubState class commands (high byte 0x40-0x4F like Ready
0x41000003). The router's SetCycle call for those would silently
override the animCycle picker's own SetCycle a few lines above
in the same UM packet — verified via SETCYCLE diag captures
showing run/walk being immediately re-cycled to Ready. Only
Action / Modifier / ChatEmote class commands (overlays that
interleave with the cycle) belong in this list iteration.
This fixed the landing-from-jump animation issue (user-confirmed:
"landing now works"). Walk↔run direct transitions still don't
visibly switch the leg cycle for observed retail-driven characters
even though ae.Sequencer.CurrentMotion correctly transitions
(per-tick SEQSTATE diag added — proves the sequencer's logical
state holds the right motion). Bug is somewhere downstream of
SetCycle's queue/state setup, possibly in Advance/BuildBlendedFrame
or in how seqFrames are applied to MeshRefs for remote entities.
Filed for next investigation.
Adds env-var-gated diagnostics (ACDREAM_REMOTE_VEL_DIAG=1):
CMD_LIST — what's in the UM's Commands list at receive time
HASCYCLE — whether the requested cycle exists in the dat
SEQSTATE — per-tick sequencer.CurrentMotion + CurrentSpeedMod
for the observed retail char (1Hz throttled)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both changes were too aggressive:
1. Full queue reset on locomotion-locomotion transitions (c06b6c5)
— turned out the user's tests went through Ready (no direct
walk↔run transitions in the wire), so the fix never fired
and didn't address the actual bug.
2. Unconditional skip of every transition link
— killed ALL transition animations across the board (jump
landing, run-to-stop, sit-down, lie-down, etc.) for every
entity, not just the locomotion-locomotion case. User
correctly identified this as a much bigger regression.
Sequencer is back to pre-c06b6c5 baseline: ClearCyclicTail-only
on motion change, transition link enqueued normally. The
walk↔run-direct-transition issue (and the broader
remote-only-doesn't-update issue) remains open and requires a
different approach.
Confirmed regression isolation: local +Acdream's transitions in
acdream client work (visible legs switch correctly), and acdream
chars observed from a parallel retail client also have working
transitions. The bug is specifically when acdream observes a
RETAIL-driven character — somewhere in the inbound
UpdateMotion → animCycle picker → SetCycle path, the visible
cycle update is being lost. Filed for separate investigation.
Adds an env-var-gated HASCYCLE diagnostic in OnLiveMotionUpdated
that confirmed cycle resolution succeeds (HasCycle=True for both
RunForward 0x44000007 and WalkForward 0x45000005 on style
0x8000003D), so the bug isn't in MotionTable cycle lookup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When AnimationSequencer.SetCycle transitions between forward-locomotion
cycles (Walk↔Run, Walk↔WalkBackward, etc.) — i.e. when both old and new
motion's low byte is in {0x05 WalkForward, 0x06 WalkBackward, 0x07
RunForward} — do a full queue drain + _currNode/_firstCyclic reset
(matching the existing skipTransitionLink branch) instead of just
ClearCyclicTail. Without this, _currNode is left pointing into the
previous cycle's non-cyclic head (link frames from the prior Ready→walk
transition), and the visible legs continue playing those head frames
before reaching the new run cycle.
Investigation findings (cdb live trace of retail at
tools/cdb-scripts/walk_run_motion_trace.log):
Retail's actual approach is "additive add_to_queue with no truncate" —
MotionTableManager handles the natural progression via per-tick
CheckForCompletedMotions / remove_redundant_links cleanup. Acdream
doesn't have that machinery, so this fix is the closest viable
emulation: force the queue back to a clean state and rebuild from
scratch on the locomotion-cycle transition.
User-reported symptom this addresses (walk→run direct transition,
release shift while W held): visible animation cycle did not switch
until next motion event. Verified via FWD_WIRE + SETCYCLE diags that
both ACE and our SetCycle are firing correctly on the transition.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the hybrid that double-counted forward translation:
predicted body.Velocity (set per-tick by apply_current_movement) +
the seqVel-derived offset both pushed the remote body forward at
~11.7 m/s × dt for run, summing to ~23.4 m/s × dt — the user's
"way too fast" + 1-Hz blip.
Per the named-retail decomp investigation 2026-05-03 (research agent
report dispatched against acclient_2013_pseudo_c.txt for
CSequence::update + UpdatePositionInternal + UpdateObjectInternal +
adjust_offset, line citations in the env-var path comments):
CPhysicsObj::UpdateObjectInternal (0x005156b0)
→ UpdatePositionInternal (0x00512c30)
→ CPartArray::Update (writes anim root motion into the offset frame)
→ PositionManager::adjust_offset (REPLACES the offset with catch-up
when the body is far from the queue head; otherwise leaves the
anim root motion alone — Frame::operator=(arg2, &__return)
semantics, NOT additive)
→ Frame::combine (out = m_position + offset)
→ UpdatePhysicsInternal (out += body.Velocity × dt + 0.5·accel·dt²)
For a remote in steady-state RunForward where the server hasn't pushed
an explicit velocity, m_velocityVector ≈ 0 and ALL per-tick translation
comes from the animation root motion (CSequence::update_internal +
Frame::combine of crossed pos_frames keyframes). Our port doesn't
extract per-keyframe pos_frames from the .anm assets; instead
AnimationSequencer.CurrentVelocity is the synthesized equivalent
(RunAnimSpeed × ForwardSpeed averaged), passed through
PositionManager.ComputeOffset.
Concrete changes in the env-var (ACDREAM_INTERP_MANAGER=1) path:
* Pass seqVel = ae.Sequencer.CurrentVelocity to ComputeOffset (was
Vector3.Zero — that disabled the animation-root-motion source and
left only the queue catch-up to drive translation, which lagged
server pace).
* Clear rm.Body.Velocity to Vector3.Zero for grounded remotes each
tick. Mirrors retail's m_velocityVector ≈ 0 for remotes; prevents
UpdatePhysicsInternal from adding a second 11.7 m/s × dt on top of
the seqVel-driven translation.
* Stop calling apply_current_movement per tick. Retail only calls it
on motion-state changes (per cdb traces from the L.5 investigation),
not per physics tick. body.Velocity-based translation is now the
AIRBORNE-only path (gravity integration during jumps).
Also reverts an unacceptable "scaling hack" (per-tick body.Velocity
scaled by observed serverSpeed/predictedSpeed) the user explicitly
rejected as patching over an unsolved structural problem.
GetMaxSpeed reverted to RunAnimSpeed × rate (matches ACE
MotionInterp.cs:670-678; the earlier "return bare rate" change came
from a misread of an x87-decompiled get_max_speed where Binary Ninja
showed the return type as void).
AnimationSequencer.SetCycle now ALWAYS overwrites CurrentVelocity for
known locomotion cycles (Walk/WalkBackward/Run/SideStepRight/
SideStepLeft) instead of gating on `CurrentVelocity.LengthSquared() <
1e-9f`. The gate was correct for non-locomotion entities with
dat-baked HasVelocity, but for Humanoid where the dat is silent and
the only thing that could set CurrentVelocity before synthesis was a
transition link's HasVelocity flag, the gate would silently leave the
body advancing at the link's velocity instead of the cycle's intended
steady-state.
Adds wire-arrival diagnostics gated on ACDREAM_REMOTE_VEL_DIAG=1
(SETCYCLE, FWD_WIRE) used to trace the bug to ground truth.
User-confirmed improvements vs prior state:
- Steady-state run no longer "way too fast"
- Run-in-circles smoother (rectangle effect gone)
- Jump landing in correct location
- Turn-left visibly turns left
Outstanding (not addressed by this commit, deferred for next
investigation): walk↔run direct transitions don't visibly switch the
animation cycle until the next motion event fires. Both legacy and
new paths exhibit the same behavior, so the bug lives in the
SetCycle queue manipulation pipeline shared by both — not in the
per-tick translation path that this commit revises. Wire trace
confirms ACE delivers the WalkForward → RunForward transition
correctly and SetCycle does fire for it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three Option-A patches addressing visual issues from the L.3.1+L.3.2
remote-entity motion path (gated by ACDREAM_INTERP_MANAGER=1):
1. Landing fallback. ACE doesn't always send IsGrounded=true on the
landing frame, so airborne remotes kept falling under gravity and
visually "disappeared into the ground" until the next non-stop UP
forced a re-snap. Track the most recent server-broadcast Z on every
UP (including mid-arc airborne ones) and, in TickAnimations, snap
the body back up + clear airborne when its predicted Z drops more
than 0.5 m below that floor.
2. TurnLeft omega sign. The synthesize-omega fallback in
AnimationSequencer (used when MotionData ships without HasOmega)
had case 0x0E using zomega = +(pi/2) * adjustedSpeed, but
adjust_motion above already remapped 0x0E to 0x0D with
adjustedSpeed = -speedMod. The double-negate produced -Z (clockwise
= right) for both turn directions, matching the reported "turning
left animates as turning right". Use the same -(pi/2) * adjustedSpeed
formula as case 0x0D so the negation lands the result on +Z (CCW).
3. Velocity diagnostic. New env var ACDREAM_REMOTE_VEL_DIAG=1 prints
one line per moving remote per ~2 seconds comparing the sequencer's
CurrentVelocity to the server's effective broadcast pace
((LastServerPos - PrevServerPos) / dt). Lets us measure the
speed-overshoot ratio that produces the residual 1-Hz blippiness
before tuning a fix.
Refs Phase L.3.1+L.3.2 spec at
docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes a multi-bug knot in player motion outbound + remote inbound,
discovered via cdb live trace of retail (2026-05-01) and follow-up
visual verification.
Outbound (acdream → ACE):
- JumpAction velocity is BODY-LOCAL, not world (per retail
CPhysicsObj::get_local_physics_velocity at 0x00512140 + ACE
Player.HandleActionJump's set_local_velocity call). Was sending
world; observers saw jump rotated by player yaw.
- Capture get_jump_v_z BEFORE LeaveGround() — the latter resets
JumpExtent to 0, after which get_jump_v_z returned 0. Was sending
Z=0 in every JumpAction.
- Backward/strafe-left jumps lost their horizontal velocity because
LeaveGround → get_state_velocity returns zero for non-canonical
motion (faithful to retail's FUN_00528960; retail papers over via
adjust_motion translation, not yet ported). Compute the correct
body-local launch velocity from input directly and push it back
into the body so local prediction matches what we send.
- IsRunning HoldKey was gated on `input.Run && input.Forward`, so
strafe-run and backward-run incorrectly broadcast as walk to
observers — ACE then animated walk + dead-reckoned at walk speed
while server position moved at run speed (visible as observer
lag). Fixed: gate on any active directional axis.
- AutonomousPosition heartbeat 0.2s → 1.0s to match holtburger's
AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the ~1Hz observed in
retail trace.
- Heartbeat now fires while in-world regardless of motion state
(matches holtburger + retail's transient_state-based gate, not
motion-based). Pre-fix the at-rest heartbeat was suppressed.
Inbound (ACE → acdream, remote retail player):
- Remote backward walk arrives as cmd=WalkForward + speed=-1.91
(retail's adjust_motion'd form). Two bugs were stacking:
1. AnimationSequencer fast-path returned without updating when
sign(speedMod) flipped while motion stayed equal — kept playing
forward at old positive framerate. Fixed: bypass fast-path on
sign change so the full re-setup runs.
2. GameWindow clamped negative speedMod to 1.0 when stuffing
InterpretedState.ForwardSpeed, making get_state_velocity
produce forward velocity. Fixed: pass speedMod through verbatim
so the dead-reckoning body translates backward.
Issue #38 filed: 30Hz physics tick produces a chase-camera smoothness
regression at 60+ FPS render. Standard render-time interpolation is
the recommended fix (separate phase).
Findings + comparison vs retail/holtburger:
docs/research/2026-05-01-retail-motion-trace/findings.md
docs/research/2026-05-01-retail-motion-trace/fixes.md
TODO: port retail's adjust_motion (FUN_00528010) properly so
get_state_velocity works for all directions natively — would let us
drop the workaround in PlayerMovementController jump path and the
clamp in GameWindow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.
Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.
This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.
Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.
Tests: 1420 → 1422.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported transition from running to jumping looked
slow -- the character stood still for ~100 ms at the start
of the jump before the legs folded into Falling.
Root cause: AnimationSequencer.SetCycle resolves a
transition link (e.g. RunForward -> Falling) from the
motion table and enqueues those non-looping link frames
BEFORE the Falling cycle. The link is the "stop running,
prepare to fall" anim -- a few frames of standing-style
pose. While it drained, the character looked frozen.
Fix: SetCycle gains a skipTransitionLink parameter. When
true, the GetLink call is bypassed AND the entire queue is
cleared (so any in-flight non-cyclic frames from a
previous transition don't continue draining). Only the
target cycle gets enqueued, cursor goes straight to its
start.
Both call sites pass true for Falling:
- OnLiveVectorUpdated (remote-jump VectorUpdate handler)
- UpdatePlayerAnimation (local airborne path) when
animCommand == Falling. Other transitions
(Walk -> Run, Run -> Ready, etc.) keep the link --
smooth transitions stay smooth, only the jump start
is hard-cut.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two animation/movement issues from live verification:
1. Walk-backward leg twitches forward two times before the cycle
reverses (X key glitch).
Root cause: AnimationSequencer.GetLink only implemented the
forward-direction lookup path. ACE's MotionTable.get_link
(MotionTable.cs:395-426) takes BOTH the substate and the new
motion's speeds, and switches lookup branches when EITHER speed
is negative:
* Forward path: Links[(style<<16) | substate][motion]
* Reversed path (any negative speed): Links[(style<<16) |
motion][substate]
For Ready → WalkBackward we adjust_motion to WalkForward at
speed -0.65 (negative). Our previous code looked up
Links[Ready][WalkForward] — the "start walking forward"
transition. Played in reverse, the cursor stranded at the
wrong cycle frame and produced the user-visible "left leg
twitches forward two times" before the cycle stabilized.
With the reversed key Links[WalkForward][Ready] (the "stop
walking → ready" anim) played at the cycle's negative speed,
the link smoothly transitions Ready → start-of-cycle, then
the cycle reverses cleanly.
GetLink signature changed from (style, fromMotion, toMotion)
to (style, substate, substateSpeed, motion, speed). Both
call sites updated: SetCycle passes CurrentSpeedMod +
adjustedSpeed; the Action-overlay path passes 1f, 1f
(action overlays are always forward).
2. Jump too low.
Two changes after deep investigation in named-retail decomp:
a) Charge rate sped up from 1.0/s → 2.0/s. Retail's PowerBar
charge constant is illegible in the named decomp (the
divisor was clobbered in GetPowerBarLevel's FPU stack
reordering at 0x0056ade0). 2.0/s (full charge in 0.5s)
matches retail muscle memory better — a tap gives a
noticeable hop, half-hold a meaningful jump, full-hold
the maximum.
b) Default jumpSkill bumped 200 → 300. Retail formula:
height = (skill / (skill + 1300)) × 22.2 + 0.05
At extent=1.0:
skill=200 → 3.01m max (felt too low)
skill=300 → 4.21m max (closer to retail mid-tier "I
can clear that fence" hop)
Override via ACDREAM_JUMP_SKILL env var.
Long-term fix is issue #7 — parsing PlayerDescription's
skill block to apply the server's authoritative skill
values. Until then, this default is the right baseline.
(Velocity formula sqrt(height × 19.6) is unchanged and
matches retail byte-for-byte; we only changed how much
extent-feeding skill we default to.)
Tests stay 1222 green. The walk-backward fix has no new test
because GetLink is private; the cycle-transition behavior
will be exercised live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.
## What changed
### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40
Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).
### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).
On UpdateMotion:
- ForwardCommand flag absent → stop signal (reset to Ready) per
retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
- Forward + sidestep + turn each route through DoInterpretedMotion,
exactly as retail FUN_00528F70 does.
- Animation cycle selection: forward wins if active, else sidestep,
else turn, else Ready. Matches the user's observation that retail
plays turn animation when only turning.
- Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
MotionData.Omega.Z ≈ π/2 per decompile).
- Turn absent → ObservedOmega = 0 (stops rotation immediately).
On UpdatePosition:
- Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
set_frame (direct assignment, no slerp — retail does not soft-snap).
- HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
- ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
alt releases W); previously we defaulted to 1.0, causing the "slow
walk that never stops" symptom.
Per-tick:
- apply_current_movement → Body.Velocity via get_state_velocity
(retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
rotated by orientation).
- Manual omega integration: Orientation *= quat(ObservedOmega × dt).
Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
was eating every-other-tick rotation updates at our 60fps render
rate — the cause of the persistent "rotation snaps every UP" bug.
- update_object still called for position integration and the motion
subsystem it drives.
### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.
### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.
## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs in the motion/animation pipeline:
1. Player's local animation was getting reset to speedMod=1.0 every ~100ms.
ACE's BroadcastMovement echoes the player's own motion back via
UpdateMotion. When ACE's ForwardSpeed == 1.0, the ForwardSpeed flag is
omitted (InterpretedMotionState.BuildMovementFlags), so our wire parser
returns null and we default to speedMod=1.0 — clobbering the
locally-authoritative 2.375 × runRate that UpdatePlayerAnimation just
set. Legs would crank up to full cadence for one frame then get slammed
back to walking rate.
Fix: for the player's own guid, skip the wire-echo SetCycle entirely.
UpdatePlayerAnimation is the authoritative driver for the local
player's animation; the server echo is only useful for observers of
other characters. User-confirmed: legs now hold their full cadence.
2. Remote entities teleported between UpdatePositions because the
sequencer's CurrentVelocity was always zero (Humanoid dat ships every
locomotion MotionData with Flags=0x00, so EnqueueMotionData leaves
CurrentVelocity at Vector3.Zero). Dead-reckoning's Priority 1
(sequencer velocity) never triggered, falling through to EMA which
has bootstrap lag + gets polluted by teleport-class server snaps.
Fix: synthesize CurrentVelocity in SetCycle from the retail locomotion
constants (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25)
× speedMod, matching the decompiled get_state_velocity (FUN_00528960)
which uses these same constants directly instead of MotionData.Velocity.
The dat's HasVelocity field is reserved for non-locomotion motions
(kick-off velocities, flying creatures, etc).
Diag confirmed synthesis fires and DR picks it up with src=seq and
correct magnitude. More visual polish may still be needed for the
"lagging remote" symptom — see follow-up.
Also adds `PlayerMovementController.BodyVelocity` utility getter for HUD/
debug use, and `ACDREAM_ANIM_SPEED_SCALE` env var as a tunable knob for
visual pacing overrides.
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: CurrentVelocity was a pass-through of the current AnimNode's
Velocity. So during a stance transition, while the link animation
played (with no velocity of its own), CurrentVelocity returned (0,0,0)
and remote dead-reckoning briefly stopped advancing the entity. Visible
as a hitch at every idle → walk or walk → run transition.
Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega
are Sequence-wide fields updated by MotionTable.add_motion's
Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new
MotionData is appended, the sequence velocity is REPLACED by that data's
velocity × speedMod. In SetCycle's rebuild path the order is:
1. clear_physics → zero
2. add_motion(link) → velocity = link's (typically 0)
3. add_motion(cycle) → velocity = cycle's (the real walk/run velocity)
After step 3, Sequence.Velocity is the CYCLE's velocity even though
CurrAnim is the link node. So dead-reckoning reads the cycle's velocity
from frame zero of the transition — no stutter.
This commit:
- Converts AnimationSequencer.CurrentVelocity / CurrentOmega from
per-node computed properties to sequence-wide private-set properties.
- Adds ClearPhysics() helper (mirrors Sequence.clear_physics).
- EnqueueMotionData now updates the sequence velocity/omega (matching
add_motion's SetVelocity semantics). Only replaces when the
MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity
modifiers don't zero the running cycle, matching retail.
- SetCycle's rebuild path calls ClearPhysics before the new add_motion
chain (matches MotionTable.cs L100-L101, L152-L153).
- MultiplyCyclicFramerate scales the sequence-wide velocity/omega
instead of per-node fields — algebraically equivalent to retail's
subtract_motion(old) + combine_motion(new) pair in change_cycle_speed.
New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that
after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's
velocity even during the link frames. Catches the old bug directly.
All 659 tests pass (was 658).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the server broadcasts a mid-run UpdateMotion with a different
ForwardSpeed (e.g. the player's RunRate changes due to stamina / skill
update), acdream must NOT restart the cycle — that would reset the
footstep cursor and look like a visible twitch. Retail handles this via
Sequence.multiply_cyclic_animation_framerate (ACE
references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287),
which walks the cyclic tail of the queue and scales each node's
framerate by newSpeed / oldSpeed. MotionTable.change_cycle_speed
(MotionTable.cs L372-L379) is the caller from the same-motion path in
GetObjectSequence (L132-L139).
This commit:
1. Adds AnimNode.MultiplyFramerate(factor) — scales a single node's
framerate. Retail also swapped StartFrame↔EndFrame for negative
factors; acdream keeps StartFrame ≤ EndFrame as an invariant and
encodes direction via Framerate sign (see existing comment in
LoadAnimNode), so we only scale. Valid because callers only ever
pass positive factors from UpdateMotion ForwardSpeed.
2. Adds AnimationSequencer.MultiplyCyclicFramerate(factor) — walks
_firstCyclic through the tail and calls node.MultiplyFramerate(factor).
Also scales each node's Velocity and Omega by the same factor so
CurrentVelocity / CurrentOmega stay aligned with playback — matches
ACE's subtract_motion + combine_motion pair in change_cycle_speed.
3. Adds AnimationSequencer.CurrentSpeedMod public property — starts at
1.0, updated by SetCycle on both restart and mid-cycle rescale.
4. Adds a speed-change fast-path to SetCycle: when the (style, motion)
pair matches the current cycle and signs agree,
MultiplyCyclicFramerate(newSpeed/oldSpeed) is called instead of
rebuilding the queue — the cursor stays where it is and the animation
continues at the new rate.
5. Wires InterpretedMotionState.ForwardSpeed from UpdateMotion through
to SetCycle in OnLiveMotionUpdated. ACE omits the ForwardSpeed flag
when speed == 1.0 (InterpretedMotionState.cs:101-103), so we default
missing/zero values to 1.0.
Tests: 4 new sequencer tests covering MultiplyCyclicFramerate,
cursor preservation across speed changes, the same-motion-different-speed
fast-path, and the same-motion-same-speed no-op guard. 632 tests green
(was 628).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnimationSequencer now walks every integer frame boundary crossed in a
tick (ACE Sequence.update_internal pattern), dispatching AnimationHook
objects whose Direction matches the playback direction (Forward or
Backward) or is Both. Mirrors ACE's Sequence.execute_hooks exactly.
New public API:
- ConsumePendingHooks() drains all hooks fired since last call, including
AnimationDone sentinel on link-node drain (emote/attack completion).
- ConsumeRootMotionDelta() drains accumulated PosFrames root motion;
AFrame.Combine (forward) / AFrame.Subtract (backward) applied per
crossed frame to match retail.
- CurrentVelocity / CurrentOmega expose the active MotionData's velocity
and omega (scaled by speedMod at enqueue), letting downstream physics
integrate the animation-driven motion.
All 27 AnimationHookType variants (SoundHook, AttackHook,
CreateParticleHook, ReplaceObjectHook, DefaultScriptHook, SetOmegaHook,
TransparentHook, ScaleHook, SetLightHook, etc.) now flow through the
hook queue. Consumers in E.2/E.3 (audio + particles) will route them to
the right subsystems.
9 new tests cover: forward-hook crossing fires exactly once, Both-direction
fires in either direction, Forward-only suppressed on reverse playback,
Backward fires on reverse, PosFrames accumulation + drain, Velocity
exposure + speedMod scaling, AnimationDone fires on link drain.
Build green; 470 tests → 479 (361 Core + 9 new E.1 hook tests + 109 Net).
Ref: docs/research/deepdives/r03-motion-animation.md §5 (hooks), §7.1-7.2
(PosFrames), §7.3 (negative framerate).
Ref: ACE Sequence.cs:262 (execute_hooks), Sequence.cs:351-443
(update_internal per-frame crossing walk).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The actual retail behavior for jump animations is a plain SubState swap —
NOT an Action overlay as I'd initially guessed. MotionCommand.Falling
(0x40000015) is a SubState cycle whose motion-table entries handle the
whole jump lifecycle:
- Links[(stance, RunForward)][Falling] = leap-into-air link
- Cycles[(stance, Falling)] = airborne cycle (loops)
- Links[(stance, Falling)][Ready/...] = landing link back to normal
Empirical verification from the diagnostic dump:
Links[0x003D0007] has 3 inner entries:
inner key: 0x41000003 (Ready)
inner key: 0x45000005 (WalkForward)
inner key: 0x40000015 (Falling) ← jackpot
SetCycle() already handles SubState + Links + Cycles resolution correctly,
so the whole fix is three lines:
if (!result.IsOnGround)
animCommand = MotionCommand.Falling;
What's in this commit:
- Added MotionCommand.Falling (0x40000015) constant + comments explaining
the retail jump-is-a-SubState flow
- GameWindow.UpdatePlayerAnimation swaps to Falling when airborne (the
cleanest possible implementation — motion table does all the work)
- Kept AnimationSequencer.PlayAction infrastructure (ported via Links
fallback + Modifiers fallback). Not needed for jump, but perfectly
valid for emotes like /wave, /bow (found in the same Links dict as
inner keys 0x13000080-0x13000083) and eventual combat attacks
- Kept MotionCommand.Jump / Jumpup / FallDown constants (unused for now
but useful reference if non-humanoid motion tables use them)
- Removed all diagnostic logging
What was learned (for future motion work):
- Retail's MotionTable.Cycles dict holds SubState loops (Ready, Walk,
Run, Falling, Crouch, etc.) by (style<<16) | (motion & 0xFFFFFF)
- MotionTable.Links dict holds TRANSITIONS between motions: the OUTER
key is the (style, fromMotion) combo; the INNER key is the TARGET
motion. The stored MotionData IS the link animation played during
the transition. This is what ACE's get_link traverses.
- MotionTable.Modifiers dict holds overlay motions (mask 0x20) — rare
for humanoids, only 8 TurnRight/SideStepRight stance variants
- Actions (mask 0x10) in retail ALSO go through Links — they're
transition animations FROM current substate, not overlays. Use
PlayAction (now correctly routed to Links dict) for them.
Jump animation now works retail-faithfully for running + jumping off.
Standing-jump behavior depends on whether the player's motion table
has a Ready→Falling link; SetCycle's fallback chain should handle it
via the style-level catch-all if the direct link is absent.
470 tests pass. Build clean.
Adds AnimationSequencer.PlayAction as the proper path for Action and
Modifier-class motions (the MotionTable.Modifiers dict, distinct from
Cycles). Action nodes are inserted before the looping cyclic tail so
they drain once and the cycle resumes naturally — leveraging the
sequencer's existing "non-looping head drains, cyclic tail wraps"
queue semantics.
What this does:
- New AnimationSequencer.PlayAction(motionCommand, speedMod=1f):
- Resolves (style<<16) | (motion&0xFFFFFF) from MotionTable.Modifiers
- Falls back to (motion&0xFFFFFF) plain key
- Silent no-op when not found (some motion tables lack these)
- Inserts AnimNodes before _firstCyclic; re-points the cursor when on
the cyclic tail so the action plays immediately
- New MotionCommand.Jump (0x2500003B) + MotionCommand.FallDown (0x10000050)
constants.
- GameWindow.UpdatePlayerAnimation fires PlayAction(Jump) on
result.JumpExtent.HasValue and PlayAction(FallDown) on JustLanded.
Key research finding: retail does NOT animate jumps.
- ACE Player.HandleActionJump explicitly clears PendingMotions and sets
IsAnimating=false during a jump (Player.cs:914-915).
- Empirical verification: the player humanoid's MotionTable only has 8
Modifier entries — all TurnRight/SideStepRight stance variants. No
Jump (0x2500003B) or FallDown (0x10000050) entries.
- Jump is a physics-only action: the character keeps whatever cycle
was active (walk/run/idle) while the physics body arcs through the
air. There is no "raise arms to jump" pose in retail.
PlayAction is still called on jump/land as a safety hatch for creature
Setups that DO carry leap animations in their Modifiers dict (drudge
jumps, monster pounces, etc.). For player humanoids it's a no-op. The
infrastructure is also ready for future emote/combat actions that
legitimately use the Modifiers dict.
470 tests pass, build clean.
ROOT CAUSE of "twitching" / stuck-on-frame-0 for reverse animations
(TurnRight with negative dat framerate, StrafeRight, etc.):
The frame swap (StartFrame↔EndFrame for negative speed) made EndFrame=0,
and GetStartFramePosition returned (0+1)-eps = 0.999. The cursor
oscillated between 0.0 and 0.999 — floor() of anything in [0,1) is
always 0, so only frame 0 ever rendered.
Fix: DON'T swap. Keep StartFrame=0, EndFrame=N-1 regardless of speed
sign. GetStartFramePosition for negative speed returns (N-1+1)-eps ≈ N,
so the cursor starts near the high end and counts down through ALL
frames. The Advance loop's reverse boundary check uses StartFrame (the
low value) correctly without the swap.
Also strips diagnostic logging from AnimationSequencer and GameWindow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:
1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
client binary. Previously float, which accumulated rounding error over long
sessions.
2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.
3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
forward and reverse; boundary detection uses EndFrame as the lower bound for
reverse playback (matching the post-swap field semantics); remainder time
propagates correctly across node boundaries for large dt values.
4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.
5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
linked list is exhausted, matching the retail loop-forever semantics.
6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
WalkBackward→WalkForward (negate×0.65 BackwardsFactor).
7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
existing implementation is correct.
AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift
All 431 tests green (109 net + 322 core).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backward-playback nodes (TurnLeft, SideStepLeft) have LowFrame >
HighFrame after multiply_framerate swaps them. Math.Clamp(x, 19, 0)
throws because min > max.
Fix: compute rangeLo/rangeHi from Min/Max of Low/High, use those
for clamping. Also step nextIdx in the playback direction (forward
+1, backward -1) instead of always +1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE FIX for missing left-side animations.
The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.
Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.
Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
left→right with speed *= -1 (TurnLeft, SideStepLeft) or
speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
SetCycle (sequencer handles remapping internally), keeps legacy
fallback for non-sequencer entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port the animation playback engine from the decompiled retail client
into AcDream.Core.Physics.AnimationSequencer.
## What this adds
**AnimationSequencer** (src/AcDream.Core/Physics/AnimationSequencer.cs):
- Frame advancer: `frameNum += framerate * dt`, bounds-checks against
AnimData.HighFrame/LowFrame (with sentinel resolution for HighFrame=-1),
wraps at cycle boundaries. Matches ACE's `Sequence.update_internal`.
- Quaternion slerp (`SlerpRetailClient`): ported from decompiled
`FUN_005360d0` (chunk_00530000.c:4799-4846):
1. dot-product sign-flip to take the shorter arc
2. fallback to linear blend when 1-dot <= 1e-4 (near-parallel)
3. sin-based slerp for all other cases
4. validate weights lie in [0,1] before using sin result (retail
client validation step that guards degenerate inputs)
- Transition link resolution: `GetLink(style, fromMotion, toMotion)`
mirrors ACE's `MotionTable.get_link` positive-speed path.
DatReaderWriter layout: `Links[style<<16|(from&0xFFFFFF)]` is a
`MotionCommandData` whose `.MotionData[toMotion]` is the transition
`MotionData`. Link frames are prepended before the cyclic tail, so
idle->walk plays the short transition clip then loops the walk cycle.
- `IAnimationLoader` / `DatCollectionLoader`: thin abstraction so the
sequencer is testable offline without opening dat files.
- Public API: `SetCycle(style, motion, speedMod)` + `Advance(dt)`
returning `IReadOnlyList<PartTransform>` (Origin+Orientation per part).
**AnimationSequencerTests** (tests/...Physics/AnimationSequencerTests.cs):
14 tests, all offline, covering slerp math, frame wrap, transition link
prepend, no-link direct switch, same-motion fast path, reset.
317 tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>