The funnel's retail-ordered dispatches now go directly into the entity's
motion-table stack via Motion/MotionTableDispatchSink (Core):
ApplyMotion → PerformMovement(InterpretedCommand), StopMotion →
PerformMovement(StopInterpretedCommand). No axis collection, no
single-cycle priority pick, no Commit pass, no HasCycle probe, no
Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 +
is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry).
Run-while-turning now blends for real: the turn is a Branch-4 modifier
combined over the untouched run substate (AP-73 DELETED from the
register).
AnimationSequencer gains the public PerformMovement passthrough (lazy
initialize_state + locomotion velocity synthesis on success — AP-75;
remote body translation via PositionManager.ComputeOffset depends on
CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss
hazard is structurally gone — GetObjectSequence checks the cycle before
any surgery; new conformance test pins sequence+state untouched on a
miss).
GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega
callbacks (H17 carried verbatim — register AP-76, retire R6); spawn +
door sites run retail's enter-world order (initialize_state installs
the table default, the wire's initial motion dispatches unguarded — the
L.1c fallback chains deleted); despawn drains the pending queue via
HandleExitWorld (MotionDone success:false per entry, 0x0051bda0).
5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4
turn blend + callback, Case-B stop unwind, Case-A stop re-drive,
miss no-op). Full suite green: 3,462 passed.
Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through
the new path, [MOTIONDONE] completions firing across entities — first
live proof of the Q3→Q4→Q5 chain. Zero exceptions.
Registers: AP-73 deleted; AP-76 added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.
DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).
GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).
Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).
Full suite green: 3,458 passed (374+425+713+1946).
Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Advance(dt, Frame? rootMotionFrame) overload: retail's actual root-
motion contract (CSequence::update(quantum, Frame*) 0x00525b80) —
every crossed frame's pos_frame combines into the caller's Frame plus
the sequence velocity/omega via apply_physics. This is the seam R6's
retail per-tick order (CPartArray.Update -> adjust_offset ->
Frame.combine) consumes.
- DELETED: ConsumeRootMotionDelta + the dead adapter accumulator fields
(zero external callers; gap-map API-migration table).
- Root-motion test now asserts REAL accumulation through the wired
Frame path (replaces the P5 inert-stub pin).
- Phase R plan: R1 stage marked SHIPPED with its commit trail.
Full suite green (3346). R1 done: P0 research/pins -> P1 node -> P2
container -> P3 physics -> P4 advance core -> P5 adapter cutover ->
P6 wiring. Next: R2 (GetObjectSequence + MotionTableManager; extraction
workflow already running).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The adapter keeps its full public surface (every consumer compiles
unchanged) while the internals move to Motion.CSequence:
DELETED (legacy invented mechanisms, per the R1 gap map + Phase R
mandate): the AnimNode class + adapter queue/_currNode/_firstCyclic/
_framePosition, the ACE-fabricated FrameEpsilon boundary math, the
safety=64 advance cap, ClearCyclicTail surgery, the stale-head
_currNode relocation block, per-node IsLooping/Velocity/Omega, the
adapter-side AdvanceToNextAnimation/ApplyPosFrame/ExecuteHooks, the
per-node !IsLooping AnimationDone gate (now the core's list-structure
head != first_cyclic gate, G5).
REHOSTED: SetCycle rebuild = RemoveCyclicAnims (+ClearAnimations for
K-fix18) + retail add_motion appends (framerate-only AnimData scaling,
G10 loop membership: the LAST appended node is the cyclic tail) + Fix B
via RemoveAllLinkAnimations (the core's snap-forward IS Fix B's
behavior); Advance = core.Update(dt, null) with hooks flowing through
an IAnimHookQueue adapter into the existing ConsumePendingHooks drain;
fast path = core.MultiplyCyclicAnimationFramerate + the adapter-level
velocity rescale (R2's change_cycle_speed composite stand-in, G13).
Kept byte-identical: adjust_motion remap, GetLink + stop-anim fallback,
K-fix18, velocity synthesis, SlerpRetailClient, the #61 render-side
clamp, SCFAST/SCFULL diagnostics.
Tests: 2 updated with decomp citations — the reverse-start boundary now
pins retail's bare-int HighFrame+1 (0x00525c80, no epsilon, G1); the
root-motion accumulation test pins the inert-stub state pending P6's
Frame wiring (G7). All 44 sequencer tests + the 56 core tests + full
suite green (3346).
Implemented by a dedicated agent against the P5 spec; diff + tests
reviewed, suite re-verified before commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new value type collided with DatReaderWriter.Types.Frame (used in
physics-adjacent code like ShadowShapeBuilder), which the structural fix
(per-file using-aliases across 6 files) would have re-incurred in every
later physics slice. Renamed the TYPE to CellFrame; the Position.Frame
MEMBER keeps retail's name. Restored the 5 alias-only files to their
pre-Slice-1 state; synced spec + plan. Core 1522 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related AnimationSequencer fixes for visible animation glitches at
motion-cycle boundaries.
1. Link-tail blend hold (closes#61). BuildBlendedFrame was wrapping
nextIdx unconditionally to rangeLo at the high-frame boundary —
correct for looping cyclic nodes (idle/run/walk loops), wrong for
one-shot links and action overlays. During the ~30 ms fractional
tail before the sequencer transitions to the next queue node, the
blend mixed frame[end] with frame[0], producing a one-frame flash
through the anim's starting pose. Symptoms: door swing-open flap
(frame 0 = closed pose) and player run-stop twitch (frame 0 =
mid-stride). Fix: gate the wrap on curr.IsLooping; non-looping
nodes hold the boundary frame until AdvanceToNextAnimation fires.
2. Stop-anim direction fallback. Stopping from WalkBackward /
SideStepLeft / TurnLeft hit a null linkData from GetLink (the dat
authors a single forward/right stop link and reuses it for both
directions). SetCycle then enqueued only the Ready cycle, snapping
straight to idle with no leg-settle blend. Fix: when the primary
GetLink lookup is null, retry with the substate's low byte remapped
to its forward/right peer (0x06→0x05, 0x10→0x0F, 0x0E→0x0D).
Both fixes are pinned by new regression tests in
AnimationSequencerTests that fail against the prior code (Y=5.02 for
the link tail wrap → frame 0 blend; Y=0 for the backward stop snapping
to Ready cycle).
Co-Authored-By: Claude Opus 4.7 (1M context) <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>
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.
Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.
Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
Fills in the test coverage gap for the rotational side of the
sequence-wide physics. Symmetric to the existing
CurrentVelocity_ScalesWithSpeedMod test: at speedMod=2.0 a
MotionData.Omega of (0,0,1) surfaces as (0,0,2). This is what the
omega rotation-integrator in TickAnimations reads each tick. 660
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>
Four new tests covering the PlayAction routing paths that the new
UpdateMotion Commands[] handler relies on:
- PlayAction_Action_ResolvesFromLinksDict — a ThrustMed attack in
SwordCombat stance resolves via Links[(SwordCombat, Ready)][ThrustMed]
and its anim frames become visible after PlayAction is called.
- PlayAction_Modifier_ResolvesFromModifiersDict — Jump (0x2500003B,
Modifier class) resolves via Modifiers[(Style, Jump)] and its anim
plays on top of the current cycle.
- PlayAction_Emote_RoutesThroughActionBranch — Wave (0x13000087, class
byte 0x13 = Action | ChatEmote | Mappable) goes through the Action
branch because the Action bit is set, resolving from Links just like
attacks. Validates the class-bit math.
- PlayAction_NoEntryInTable_IsNoOp — silent no-op when the table has
no entry for the motion, with the queue length unchanged.
Together these lock in that the same PlayAction path correctly routes
the three major one-shot classes the Commands[] handler fans out to
NPCs and remote players. 658 tests green (was 654).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: remote characters stutter-hop between UpdatePosition broadcasts
(typical 100-200ms interval), looking lagging-forward during continuous
motion. The retail client hides this gap by integrating velocity forward
each tick — apply_current_movement in chunk_00520000.c L7132-L7189,
mirrored by holtburger's project_pose_by_velocity in spatial/physics.rs.
Strategy:
1. RemoteDeadReckonState per remote entity tracks the last authoritative
server position + rotation, an EMA-smoothed observed velocity from
position deltas, and any server-supplied HasVelocity vector.
2. OnLivePositionUpdated: on each UpdatePosition arrival, snap the entity
to the server position, then update the dead-reckon state. The
observed-velocity is a 50/50 EMA against the running average so a
single jitter sample doesn't blow out the velocity.
3. TickAnimations: each tick, for every remote entity in a locomotion
cycle, integrate Entity.Position += worldVelocity * dt. World velocity
is pulled in priority order:
- Sequencer's MotionData.Velocity rotated by Entity.Rotation (the
primary source; matches MotionData's "world-space on the object"
convention per r03 §1.3)
- Server-supplied HasVelocity from UpdatePosition (already world-space)
- EMA-observed position-delta velocity (fallback for NPC motion
tables with HasVelocity=0)
4. Cap: if the predicted position drifts more than velocity ×
DeadReckonMaxPredictSeconds (1.0s) from the last server position,
clamp back toward the server. This prevents runaway when sequencer
velocity and server reality disagree (e.g. server rubber-banding).
Result: remote chars now move smoothly between position updates,
matching the retail client's visual feel. When UpdatePosition arrives
the entity snaps to the authoritative position and the dead-reckon
origin resets, so there's no accumulating drift.
Tests: CurrentVelocity_ScalesWithSpeedMod — new unit test verifying
that the sequencer's CurrentVelocity accurately reflects speedMod changes
across both SetCycle's rebuild path and its rescale path. Combined with
the existing MultiplyCyclicFramerate tests, this validates the
downstream-visible velocity surface the dead-reckoner reads. 633 tests
green (was 632).
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>
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>
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>