fix(anim): implement adjust_motion — TurnLeft/SideStepLeft play backward

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>
This commit is contained in:
Erik 2026-04-13 12:17:26 +02:00
parent 0e66078e57
commit 67b51a3e6f
3 changed files with 339 additions and 35 deletions

View file

@ -0,0 +1,279 @@
# acclient.exe Animation System Function Map
Mapped from decompiled C chunks against ACE's C# animation port.
Cross-referenced with `references/ACE/Source/ACE.Server/Physics/`.
---
## CPartArray (PhysicsObj+0x10 ptr)
PartArray is heap-allocated and accessed via a pointer stored at `PhysicsObj+0x10`.
The Sequence is embedded inside PartArray at `PartArray+0x08`.
### Struct Layout
| Offset | Field | Type | Notes |
|--------|-------|------|-------|
| +0x00 | State | uint | Flags (0x10000 = HasPhysicsBSP) |
| +0x04 | Owner | ptr | PhysicsObj* |
| +0x08 | Sequence | embedded | 68 bytes of Sequence struct |
| +0x50 | MotionTableManager | ptr | null if no motion table |
| +0x54 | Setup | ptr | CSetup dat object |
| +0x58 | NumParts | uint | Part count |
| +0x5c | Parts | ptr | Array of PhysicsPart* |
| +0x60 | Scale | float[3] | XYZ scale (default 1,1,1) |
| +0x70 | ShadowParts | ptr | Shadow part array |
### Functions (chunk_00510000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x510140 | FUN_00510140 | PartArray::UpdateParts | Calls SetFrame when anim flag set |
| 0x512DE0 | FUN_00512de0 | AFrame::Combine | Quaternion compose + position transform |
| 0x513730 | FUN_00513730 | PhysicsObj::UpdatePositionInternal | Advances physics: calls PartArray::Update then SetFrame |
| 0x514B90 | FUN_00514b90 | PhysicsObj::UpdateObjectInternal | Calls PartArray::Update (SetFrame) |
| 0x518790 | FUN_00518790 | PartArray::CopyShadowParts | Copies frame data to shadow parts array (stride 0x48/part) |
| 0x5188E0 | FUN_005188e0 | PartArray::Update | Thin wrapper: calls Sequence::Update (FUN_00526780) |
| 0x519B00 | FUN_00519b00 | PartArray::SetPartFrame | Per-part transform: matrix3×3 + quaternion multiply → part position |
| 0x519C20 | FUN_00519c20 | PartArray::UpdateParts | Loops over NumParts (stride 0x1c per anim frame part), calls SetPartFrame |
| 0x519E40 | FUN_00519e40 | PartArray::SetFrame | Top-level frame apply: calls UpdateParts + CopyShadowParts |
**Key pattern in FUN_00519b00** (SetPartFrame):
Computes `pos = matrix3x3 * scale + parentPos`, then calls `FUN_00535dc0` (quaternion normalize) to compose rotation. The matrix at `animFrame[partIdx]` has 16 floats:
`[0..3]`=quaternion, `[4..0xc]`=rotation matrix (computed from quat), `[0xd..0xf]`=XYZ position.
---
## CSequence (embedded at PartArray+0x08)
### Struct Layout
| Offset | Field | Type | Notes |
|--------|-------|------|-------|
| +0x00 | vtable | ptr | Points to &PTR_FUN_007c92a0 |
| +0x04 | AnimList.Head | ptr | First node in linked list |
| +0x08 | AnimList.Tail | ptr | Last node sentinel |
| +0x0c | FirstCyclic | ptr | Node before first cyclic anim |
| +0x10 | Velocity.X | float | |
| +0x14 | Velocity.Y | float | |
| +0x18 | Velocity.Z | float | |
| +0x1c | Omega.X | float | Angular velocity |
| +0x20 | Omega.Y | float | |
| +0x24 | Omega.Z | float | |
| +0x28 | HookObj | ptr | PhysicsObj for animation hooks |
| +0x30 | FrameNumber | double | Current frame (fractional) |
| +0x38 | CurrAnim | ptr | Current AnimSequenceNode* |
| +0x3c | PlacementFrame | ptr | AnimationFrame* (used when no anims) |
| +0x40 | PlacementFrameID | int | Placement ID (e.g. 0x65) |
Total size: ~0x44 = 68 bytes.
### Functions (chunk_00520000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x5254C0 | FUN_005254c0 | Sequence::CombinePhysics | Adds velocity+omega to sequence totals |
| 0x525500 | FUN_00525500 | Sequence::subtract_physics | Subtracts velocity+omega |
| 0x525540 | FUN_00525540 | Sequence::remove_all_link_animations | Removes all pre-cyclic nodes from list |
| 0x525570 | FUN_00525570 | Sequence::GetCurrAnimFrame | If CurrAnim≠null: floor(FrameNumber)→get_part_frame; else PlacementFrame |
| 0x5255B0 | FUN_005255b0 | Sequence::SetPlacementFrame | Sets PlacementFrame ptr (+0x3c) and ID (+0x40) |
| 0x5255D0 | FUN_005255d0 | Sequence::get_curr_frame_number | floor(FrameNumber) |
| 0x5255F0 | FUN_005255f0 | Sequence::Init | Zero-initializes all fields, sets vtable |
| 0x525630 | FUN_00525630 | Sequence::clear_animations | Walks linked list, releases all nodes |
| 0x5256B0 | FUN_005256b0 | Sequence::apply_physics | frame.Origin += Velocity\*quantum; frame.Rotate(Omega\*quantum) |
| 0x525740 | FUN_00525740 | Sequence::apricot | Removes consumed nodes up to CurrAnim |
| 0x525EB0 | FUN_00525eb0 | Sequence::advance_to_next_animation | Moves CurrAnim to next/prev node or wraps to FirstCyclic |
| 0x526110 | FUN_00526110 | Sequence::append_animation | Allocates AnimSequenceNode (0x1c bytes), links at tail, sets FirstCyclic and CurrAnim if first |
| 0x5261B0 | FUN_005261b0 | Sequence::Clear | Calls clear_animations + clear_physics, zeroes PlacementFrame |
| 0x5261D0 | FUN_005261d0 | Sequence::update_internal | **Core update loop**: `FrameNumber += Framerate * elapsed`; handles forward/backward; fires hooks; calls advance_to_next_animation when done |
| 0x526780 | FUN_00526780 | Sequence::Update | Wrapper: if AnimList not empty → update_internal + apricot; else apply_physics only |
**Key pattern in FUN_005261d0** (update_internal):
```c
// Forward direction (frametime > 0):
while (floor(frameNum) > lastFrame) {
apply_physics + execute_hooks(lastFrame);
lastFrame++;
}
if (floor(frameNum) > get_high_frame()) {
frameTimeElapsed = (frameNum - get_high_frame() - 1.0) / framerate;
frameNum = get_high_frame();
advance_to_next_animation(timeElapsed, ...);
recurse with remaining time
}
```
---
## CAnimSequenceNode (linked list node, 0x1c bytes)
### Struct Layout
| Offset | Field | Type | Notes |
|--------|-------|------|-------|
| +0x00 | prev | ptr | Previous linked list node |
| +0x04 | next | ptr | Next linked list node |
| +0x08 | (sentinel) | ptr | |
| +0x0c | Anim | ptr | Animation* dat object |
| +0x10 | Framerate | float | Frames per second (default 30.0) |
| +0x14 | LowFrame | int | Start frame |
| +0x18 | HighFrame | int | End frame (-1 = use all) |
Total size: 0x1c = 28 bytes (heap allocated via `FUN_005df0f5(0x1c)`).
### Functions (chunk_00520000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x526810 | FUN_00526810 | AnimSequenceNode::get_pos_frame | Returns PosFrames[frameIdx] at ptr+frame\*0x1c |
| 0x526840 | FUN_00526840 | AnimSequenceNode::get_part_frame | Returns PartFrames[frameIdx] at ptr+frame\*0x10 |
| 0x526870 | FUN_00526870 | AnimSequenceNode::has_anim | Returns Anim != null |
| 0x526880 | FUN_00526880 | AnimSequenceNode::get_starting_frame | LowFrame if Framerate≥0, else HighFrame+1-ε |
| 0x5268B0 | FUN_005268b0 | AnimSequenceNode::get_ending_frame | HighFrame+1-ε if Framerate≥0, else LowFrame |
| 0x5267E0 | FUN_005267e0 | AnimSequenceNode::multiply_framerate | Swaps Low/High if multiplier<0; Framerate \*= multiplier |
**Animation dat object layout** (referenced from AnimSequenceNode+0x0c):
- `Anim+0x38`: PosFrames array ptr (AFrame per frame, stride 0x1c)
- `Anim+0x3c`: PartFrames array ptr (AnimationFrame per frame, stride 0x10 per part)
- `Anim+0x48`: NumFrames (int)
---
## MotionTable / MotionTableManager (chunk_00520000.c)
### Cycle Key Construction
The cycle key is built as: `(motionCategory << 16) | (command & 0xFFFFFF)`
Confirmed at lines 2469, 2474, 2491, 2551, 2597, 2660, 2699 in chunk_00520000.c:
```c
key = param_4 & 0xffffff | param_1 << 0x10;
// or equivalently:
key = (int)*param_3 << 0x10 | (uint)param_2 & 0xffffff;
```
### Hash Map Structure (used for all MotionTable lookups)
The hash map has:
- `+0x04`: hash mask (capacity - 1)
- `+0x08`: hash shift
- `+0x0c`: bucket array ptr
- Each bucket is a linked list of nodes; each node has key at `+0x08`, value at `+0x0c`, next at `+0x04`
Hash formula: `bucketIdx = (key >> hashShift ^ key) & hashMask`
### Functions
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x520A00 | FUN_00520a00 | MotionTableManager::Init | Initializes all MTM fields (large constructor) |
| 0x520BB0 | FUN_00520bb0 | MotionTableManager::Create | Factory: allocates 0xd8 bytes, calls Init |
| 0x521770 | FUN_00521770 | MotionTable::unpack | Deserializes MotionTable from dat stream |
| 0x521F10 | FUN_00521f10 | MotionTable::find_motion | Hash map lookup by motionID |
| 0x521F60 | FUN_00521f60 | MotionTable::destructor | Frees all MotionTable allocations |
| 0x522E30 | FUN_00522e30 | MotionTableManager::SetCurrentMotion | Sets current state node |
| 0x523000 | FUN_00523000 | MotionTable::add_animations_to_sequence | Applies velocity/omega + appends AnimData to Sequence |
| 0x5230D0 | FUN_005230d0 | MotionTable::set_physics | Sets velocity+omega on Sequence only |
| 0x523150 | FUN_00523150 | MotionTable::subtract_physics | Subtracts velocity+omega from Sequence |
| 0x5231D0 | FUN_005231d0 | MotionTable::FindObjectAndMotionData | Hash lookup returning node ptr-4 |
| 0x523210 | FUN_00523210 | MotionTable::FindObjectAndCycle | Hash lookup returning cycle data at node+0xc |
| 0x523260 | FUN_00523260 | MotionTable::CheckAlreadySet | Checks if motion already current at same speed |
| 0x5232B0 | FUN_005232b0 | MotionTable::GetObjectAnimData | Builds cycle key (`src<<16 \| dst&0xFFFFFF`), calls FindObjectAndMotionData |
| 0x523400 | FUN_00523400 | MotionTableManager::PerformMovement | **Core motion dispatch**: transitions between states, populates Sequence |
**FUN_00523400 (PerformMovement) key pattern**:
```c
// Transition key for cycle lookup:
key = (int)*param_3 << 0x10 | (uint)local_8 & 0xffffff;
anim_data = FUN_005231d0(key); // FindObjectAndMotionData
// Then:
FUN_00523000(sequence, link_anim, speed); // add link animations
FUN_00523000(sequence, from_anim, speed); // add departure animation
FUN_00523000(sequence, to_anim, speed); // add transition animation
FUN_00523000(sequence, cycle_anim, speed); // add cyclic animation
```
---
## AFrame (chunk_00530000.c)
AFrame is a combined rotation+position: quaternion (4 floats) + 3×3 matrix (derived) + XYZ (3 floats).
### Struct Layout (0x1c = 28 bytes total)
| Offset | Field | Type |
|--------|-------|------|
| +0x00..+0x0c | Quaternion W,X,Y,Z | float[4] |
| +0x10..+0x30 | Matrix 3×3 (derived) | float[9] |
| +0x34..+0x3c | Origin XYZ | float[3] |
Note: In Ghidra decompiled code the struct appears as offsets in uint32s:
`[0..3]` = quaternion, `[4..0xc]` = matrix, `[0xd..0xf]` = origin.
### Functions
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x535B30 | FUN_00535b30 | AFrame::update_matrix | Quaternion → 3×3 rotation matrix (stores in [4..0xc]) |
| 0x535C10 | FUN_00535c10 | AFrame::is_valid | Checks NaN in origin [0xd..0xf] and quaternion [0..3] |
| 0x535DC0 | FUN_00535dc0 | AFrame::normalize_quaternion | Normalizes quat: each *= 1/sqrt(dot); validates with is_valid |
| 0x535E70 | FUN_00535e70 | AFrame::pack_quaternion | Serializes quaternion [0..3] + origin [0xd..0xf] to byte stream |
---
## PhysicsObj Animation-Relevant Offsets (chunk_00510000.c)
| Offset | Field | Notes |
|--------|-------|-------|
| +0x10 | PartArray ptr | null = no animation; checked before every anim call |
| +0x50 | Position.Frame | AFrame (embedded); updated by FUN_00512de0 (AFrame::Combine) |
| +0xA8 | State | bit 0x4000 = no-animate flag; bit 0x1000 = invisible |
| +0xD8 | LastUpdateTime | double; used to compute dt for Sequence::Update |
| +0x114 | RunRate | float; velocity scale applied to omega after anim update |
---
## Call Chain: Per-Frame Animation Update
```
PhysicsEngine::update (FUN_00452A10)
→ PhysicsObj::update_object (FUN_00515020)
→ PhysicsObj::UpdatePositionInternal (FUN_00513730)
→ PartArray::Update (FUN_005188E0) [if PhysicsObj+0x10 != null]
→ Sequence::Update (FUN_00526780)
→ Sequence::update_internal (FUN_005261D0)
→ AnimSequenceNode::get_part_frame (FUN_00526840)
→ Sequence::execute_hooks (via FUN_00525430)
→ Sequence::advance_to_next_animation (FUN_00525EB0)
→ AnimSequenceNode::get_starting_frame (FUN_00526880)
→ Sequence::apply_physics (FUN_005256B0)
→ Sequence::apricot (FUN_00525740)
→ AFrame::Combine (FUN_00512DE0) [combines offsetFrame into PhysicsObj.Position]
→ PartArray::SetFrame (FUN_00519E40) [if PhysicsObj+0x10 != null]
→ PartArray::UpdateParts (FUN_00519C20)
→ Sequence::GetCurrAnimFrame (FUN_00525570)
→ PartArray::SetPartFrame (FUN_00519B00) [per-part matrix*scale+quaternion]
→ PartArray::CopyShadowParts (FUN_00518790) [if shadow parts exist]
```
---
## Key Constants and Identifiers
| Constant | Value | Meaning |
|----------|-------|---------|
| AnimSequenceNode size | 0x1c (28) | heap alloc size |
| AnimationFrame stride (PartFrames) | 0x10 per part | 4 floats: quaternion per part |
| AFrame size | 0x1c (28) | quaternion+matrix+pos |
| AnimData stride in MotionTable nodes | 0x1c | 7 uint32s per entry |
| PosFrames stride | 0x1c per frame | AFrame per position keyframe |
| PhysicsObj.PartArray offset | +0x10 | pointer field |
| PhysicsObj.Position.Frame offset | +0x50 | AFrame embedded |
| PartArray.NumParts offset | +0x58 (relative to PartArray) | uint |
| PartArray.Scale offset | +0x60 (relative to PartArray) | float[3] |
| Cycle key formula | `(category << 16) \| (cmd & 0xFFFFFF)` | MotionTable lookup |
| Default framerate | 30.0 (0x41F00000) | AnimSequenceNode default |
| PhysicsGlobals.EPSILON | from _DAT_007c92b4 | ~1e-5 |
| No-animate state flag | 0x6500000d | Walk/Run forward (left) |
| No-animate state flag | 0x6500000f | Walk/Run backward |