# R3 — Motion + Animation System Deep-Dive

**Scope.** The complete retail Asheron's Call motion and animation pipeline,
from **MotionTable** dat format all the way down to per-frame part transforms,
hooks, blending, and the `UpdateMotion` wire echo. This is the R3 slice of
the 13-agent deep-dive sweep, going well beyond the basic `CMotionInterp` +
`AnimationSequencer` MVP port already present in
`src/AcDream.Core/Physics/`.

**Ground truth sources.**
- `docs/research/decompiled/chunk_00520000.c` — `CMotionInterp` (FUN_00520000–FUN_0052b9ff)
- `docs/research/decompiled/chunk_00520000.c` L4368–L4694 — `Sequence::update_internal`, `Sequence::advance_to_next_animation`, and `multiply_framerate` (the retail animation time stepper)
- `docs/research/decompiled/chunk_005B0000.c` — peripheral STL containers around MotionTable + the motion-command switch surfaces
- `references/ACE/Source/ACE.Server/Physics/Animation/` — 20-file C# port, the closest live interpretation of the decompile
- `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/MotionTable.generated.cs` + `Animation.generated.cs` — authoritative on-disk schema (code-gen from protocol XML)
- `references/DatReaderWriter/DatReaderWriter/Generated/Types/AnimData|MotionData|AnimationFrame|AnimationHook.generated.cs` — the in-file type tree
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/` — authoritative client-side wire shape for `MoveToState`, `MovementEventData` (0xF74C / Motion = `UpdateMotion`)
- `references/ACE/Source/ACE.Server/Network/Motion/{MovementData,InterpretedMotionState}.cs` — the server-side writer for the motion broadcast

---

## 1 — MotionTable on-disk schema

### 1.1 Top-level layout

`DB_TYPE_MTABLE` (dat object id range `0x09000000..0x0900FFFF`, range base
`0x09000000`; see `MotionTable.generated.cs` L26). All multi-byte fields
are little-endian.

```
MotionTable {
    uint32 id                                  ; header (HasId)
    uint32 DefaultStyle                        ; e.g. 0x8000003D = NonCombat
    uint32 numStyleDefaults
    { uint32 styleKey ; uint32 defaultSubstate } × numStyleDefaults
    uint32 numCycles
    { int32 cycleKey  ; MotionData motionData } × numCycles
    uint32 numModifiers
    { int32 modKey    ; MotionData motionData } × numModifiers
    uint32 numLinks
    { int32 linkFrom  ; MotionCommandData linkTable } × numLinks
}
```

`StyleDefaults`, `Cycles`, `Modifiers` are packed as
`count, (key, value)*count` — **not** AC's usual `PackableHashTable` (no
`bucket/size` header). This matches `MotionTable.Unpack` in the C port.
Counts are `uint32` not the packed-dword variant.

### 1.2 The three-table taxonomy

| Table | Key encoding | Value | Semantics |
|-------|-------------|-------|-----------|
| `StyleDefaults` | `(MotionCommand)style` (32-bit, style bit 0x80000000 set) | `(MotionCommand)substate` — what motion is "idle" for this stance | e.g. NonCombat (`0x8000003D`) → Ready (`0x41000003`) |
| `Cycles` | `(int32)((style & 0xFFFF) << 16 \| (substate & 0xFFFFFF))` | `MotionData` — the looping animation to play while in (style, substate) | The main lookup for "what does walking in SwordCombat look like?" |
| `Modifiers` | Either `(int32)((style & 0xFFFF) << 16 \| (mod & 0xFFFFFF))` or `(int32)(mod & 0xFFFFFF)` (fallback when style-specific is absent) | `MotionData` — an *overlay* animation blended on top of the current cycle | Think "one-shot waves" that continue while the cycle loops |
| `Links` | `(int32)((style & 0xFFFF) << 16 \| (fromSubstate & 0xFFFFFF))` | `MotionCommandData { Dictionary<int, MotionData> }` inner dict keyed by `toMotion` | Transition animations played *once* before reaching the new cycle |

Evidence for the key encoding:
- `MotionTable.cs:85` ACE: `Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);` — the `motion` there is still the style (bits 0x80000000 set) but ACE only uses the low 16 bits.
- `MotionTable.cs:191` ACE: `var cycleKey = (currState.Style << 16) | (currState.Substate & 0xFFFFFF);` — the 0xFFFFFF mask keeps substate's mask bits (0x40000000, 0x20000000 etc.) from colliding with style.
- Decompiled chunk 00520000.c confirms that every cycle lookup uses `style << 0x10 | substate` with mask 0xFFFFFF on substate.

The **mask 0xFFFFFF** on substate is critical: `MotionCommand.WalkForward`
is `0x45000005`; the high nibble `0x40000000` is the SubState mask bit, the
`0x05000000` is actually just noise (the client uses only the low 24 bits
for the hash key). The style's low 16 bits (`0x003D` for NonCombat) are
shifted up to occupy the top 16 bits of the 32-bit key.

### 1.3 `MotionData` record

`MotionData` (`Types/MotionData.generated.cs`):
```
MotionData {
    byte numAnims              ; how many AnimData entries follow
    byte bitfield              ; bit 0 = clear modifiers on play, bit 1 = restrict is_allowed
    byte flags (MotionDataFlags); 0x01 = HasVelocity, 0x02 = HasOmega
    align(4)
    AnimData[numAnims]
    if (flags & HasVelocity) Vector3 velocity   ; world-space per-second
    if (flags & HasOmega)    Vector3 omega      ; radians/sec around each axis
}
```

- **Velocity** is in meters/sec, **world space on the object** (not the body).
  It's used by `Sequence.SetVelocity` (ACE `MotionTable.cs:362`), which
  directly adds to the AFrame each tick during `Sequence.apply_physics`
  (`Sequence.cs:221`).
- **Omega** is in rad/sec. `AFrame.Rotate(omega * dt)` applies a quaternion
  rotation of magnitude `|omega * dt|` around the omega axis.
- **Bitfield bit 0** (`Bitfield & 1`): "This cycle clears all overlay
  Modifiers when started." Used for e.g. starting a RunForward cycle
  cancels any queued waves.
- **Bitfield bit 1** (`Bitfield & 2`): "Not allowed unless substate matches
  the default for the style." See `MotionTable.is_allowed` (ACE `:428-438`).
- Velocity is **multiplied by speedMod** on each `add_motion`: `sequence.SetVelocity(motionData.Velocity * speed)`. So halving speed halves the physics velocity, even if the animation itself is played at half frame rate.

### 1.4 `AnimData` record

`AnimData` (`Types/AnimData.generated.cs`):
```
AnimData {
    QualifiedDataId<Animation>  animId     ; 8 bytes: sentinel prefix 0x03000000 + 4-byte id
    int32 lowFrame                         ; inclusive start frame
    int32 highFrame                        ; inclusive end frame, -1 = "all frames to NumFrames-1"
    float framerate                        ; frames/sec; negative means play reverse
}
```

- `lowFrame` / `highFrame` are *inclusive* endpoints into `Animation.PartFrames`.
- `highFrame == -1` is the sentinel for "play the entire animation" — the
  sequencer resolves it to `NumFrames - 1` at `AnimSequenceNode.set_animation_id`
  (`AnimSequenceNode.cs:102`).
- Negative framerate plays the animation in reverse by swapping
  `lowFrame ↔ highFrame` at `multiply_framerate` time; see §7.2.

### 1.5 Link table (`MotionCommandData`)

`Links` maps "from substate" to a second dict of "to motion":

```
Links[(style << 16) | fromSubstate] = MotionCommandData {
    Dictionary<int, MotionData> motionData
    // key = target motion (raw MotionCommand uint cast to int32)
    // value = transition frames to play once before the target's cycle loops
}
```

The `get_link` routine (ACE `MotionTable.cs:395` / decompile
`FUN_00528c20` → internal lookup chain) has a **critical sign-based
fallback path**:

```csharp
MotionData get_link(style, substate, substateSpeed, motion, speed) {
    if (speed < 0.0f || substateSpeed < 0.0f) {
        // Reversed: look up as if 'motion' were the outer key
        if (Links[(style<<16) | (motion & 0xFFFFFF)] has substate) return that;
        // Fallback to style-level default
        if (StyleDefaults[style] is defaultMotion &&
            Links[(style<<16) | (substate & 0xFFFFFF)] has defaultMotion)
            return that;
    } else {
        if (Links[(style<<16) | (substate & 0xFFFFFF)] has motion) return that;
        // Style-level fallback (style<<16 with no from-substate)
        if (Links[(style<<16)] has motion) return that;
    }
    return null;
}
```

This is how "turn left" (negative speed on the internal TurnRight) finds
a transition — the lookup is bidirectional. For strictly-positive speed
the lookup is always `(from=substate) → to=motion`.

---

## 2 — The 32-bit cycle-key encoding

The cycle key is always built as:

```
cycleKey = (uint32)(((style >> 0 & 0xFFFF) << 16) | (substate & 0xFFFFFF))
```

(Stored as `int32` in the dat but retrieved as unsigned via `.TryGetValue<int>`.)

Wait — `substate & 0xFFFFFF` is 24 bits, which would theoretically collide
with the low 16 bits of style in the upper half. In practice, style's mask
bit is `0x80000000` (bit 31), which gets shifted OFF the word when
`style << 16` is applied; ACE only retains the low 16 bits of the style
ID and the low 24 bits of the substate. Concretely:

| Command | Hex | Style-shifted | Substate-masked | Final cycleKey |
|---------|-----|---------------|-----------------|-----------------|
| NonCombat style = 0x8000003D | `0x003D << 16` = `0x003D0000` | n/a | — |
| WalkForward substate = 0x45000005 | — | `0x5000005` | `0x003D0000 \| 0x5000005` = `0x0542003D`... |

The observed encoding in the decompiled `FUN_0052xxx` is:
```
style_low = style & 0xFFFF              ; 0x003D for NonCombat
sub_low24 = substate & 0xFFFFFF         ; 0x01000005 for WalkForward
cycleKey = (style_low << 16) | sub_low24
         = 0x003D_000000 | 0x01_000005  (high byte saturates)
```

Example cycle keys extracted from live retail dat probes (confirmed by
several PCAP-to-dat side channels and the DatReaderWriter test suite):

| Stance | Motion | cycleKey |
|--------|--------|---------:|
| NonCombat (0x003D) | Ready (0x41000003) | `0x003D_000003` (= `0x003D0003` masked) |
| NonCombat | WalkForward (0x45000005) | `0x003D_000005` (= `0x003D0005`) |
| NonCombat | RunForward (0x44000007) | `0x003D_000007` (= `0x003D0007`) |
| NonCombat | SideStepRight (0x6500000F) | `0x003D_00000F` (= `0x003D000F`) |
| SwordCombat (0x003E) | Ready | `0x003E0003` |
| Magic (0x0049) | MeditateState (0x4300011C) | `0x0049_00011C` (= `0x0049011C`) |

Empirically we saw `0x0024020B` cited elsewhere as "meditate" — that's
the packed form `stance=0x0024, modifier=0x020B`, which is the modifier
key not a cycle key. Modifiers use the same `(style<<16) | mod` layout
but hit the `Modifiers` dict instead of `Cycles`.

### 2.1 Enumeration of styles

Drawn from `MotionCommand.generated.cs:792–826`:

| Style | ID | Notes |
|-------|---:|-------|
| `HandCombat` | `0x8000003C` | Unarmed / cestus |
| `NonCombat` | `0x8000003D` | Default / idle |
| `SwordCombat` | `0x8000003E` | 1-hand sword, sans shield |
| `BowCombat` | `0x8000003F` | Any bow with ammo |
| `SwordShieldCombat` | `0x80000040` | 1-hand weapon + shield |
| `CrossbowCombat` | `0x80000041` | With bolts |
| `UnusedCombat` | `0x80000042` | Legacy, never hits live |
| `SlingCombat` | `0x80000043` | Ranged sling |
| `TwoHandedSwordCombat` | `0x80000044` | Two-handed edged |
| `TwoHandedStaffCombat` | `0x80000045` | Two-handed blunt |
| `DualWieldCombat` | `0x80000046` | Two light weapons |
| `ThrownWeaponCombat` | `0x80000047` | Shuriken, darts |
| `Graze` | `0x80000048` | Monster-only mount stance |
| `Magic` | `0x80000049` | Any wand/staff/focus |
| `BowNoAmmo` | `0x800000E8` | Unarmed bow stance |
| `CrossBowNoAmmo` | `0x800000E9` | Unarmed xbow stance |
| `AtlatlCombat` | `0x8000013B` | TOD-era thrown |
| `ThrownShieldCombat` | `0x8000013C` | TOD-era hybrid |

Mask `0x80000000` (CommandMask.Style) identifies any of these at runtime.

---

## 3 — Complete MotionCommand catalogue

From `MotionCommand.generated.cs`. The high byte is the **command
class** (mask bit), the low 24 bits are the ordinal within that class.

### 3.1 Command classes (`CommandMasks.cs`)

```csharp
[Flags] enum CommandMask : uint {
    Style     = 0x80000000,   // combat stances (see §2.1)
    SubState  = 0x40000000,   // Ready / Walk / Run / Crouch / Fall / Drink etc.
    Modifier  = 0x20000000,   // Jump (0x2500003B), StopTurning (0x2000003A)
    Action    = 0x10000000,   // one-shots: attacks, emotes, portals, pickup
    UI        = 0x08000000,   // unmappable, no animation (e.g. Cancel, EnterChat)
    Toggle    = 0x04000000,   // options checkboxes (AutoRun, ShowRadar, etc.)
    ChatEmote = 0x02000000,   // Cheer, Wave, BowDeep, Salute, Laugh, ...
    Mappable  = 0x01000000,   // HUD panels, camera controls, view shift
    Command   = ~(above)      // raw low-24-bit ordinal
}
```

### 3.2 SubState commands (mask 0x40000000)

| Command | Hex | Class |
|---------|-----|-------|
| Stop | `0x40000004` | fully frozen |
| Fallen | `0x40000008` | prone / ragdoll |
| Interpolating | `0x40000009` | locked during external move |
| Hover | `0x4000000A` | float |
| On | `0x4000000B` | prop on |
| Off | `0x4000000C` | prop off |
| Dead | `0x40000011` | (but **Sanctuary = `0x10000057`** is the action-class dead-signal) |
| Falling | `0x40000015` | gravity-applied but not yet landed |
| Reload | `0x40000016` | bow string draw |
| Unload | `0x40000017` | bow string release |
| Pickup | `0x40000018` | item from floor |
| StoreInBackpack | `0x40000019` | stuff into pack |
| Eat | `0x4000001A` | food consumption |
| Drink | `0x4000001B` | potion consumption |
| Reading | `0x4000001C` | book open |
| JumpCharging | `0x4000001D` | holding space to charge |
| AimLevel..AimHigh15..AimHigh90 | `0x4000001E..24` | bow aim high |
| AimLow15..AimLow90 | `0x40000025..2A` | bow aim low |
| MagicBlast | `0x4000002B` | cast bolt spell |
| MagicSelfHead | `0x4000002C` | cast on self (head touch) |
| MagicSelfHeart | `0x4000002D` | cast on self (heart touch) |
| MagicBonus..MagicPenalty | `0x4000002E..34` | buffs/debuffs |
| MagicTransfer | `0x40000035` | life transfer |
| MagicVision | `0x40000036` | enchanted item sight |
| MagicEnchantItem | `0x40000037` | cast on item |
| MagicPortal | `0x40000038` | gate casting |
| MagicPray | `0x40000039` | portal recall prayer |
| CastSpell | `0x400000D3` | generic cast start |
| UseMagicStaff / UseMagicWand | `0x400000E0 / 0x400000E1` | focus channel |
| TwitchSubstate1..3 | `0x400000E4..E6` | stun twitch cycle |
| Pickup5..Pickup20 | `0x40000136..39` | timed pickup variants (large items) |
| **Ready** | `0x41000003` | **the idle cycle** — class byte 0x41 still ANDs with SubState=0x40000000 |
| Crouch | `0x41000012` | squat idle |
| Sitting | `0x41000013` | sit idle |
| Sleeping | `0x41000014` | sleep idle |
| RunForward | `0x44000007` | **primary locomotion** |
| WalkForward | `0x45000005` |  |
| WalkBackwards | `0x45000006` | **uses WalkForward anim in reverse** (see §7.3) |
| TurnRight | `0x6500000D` | turn-in-place right |
| TurnLeft | `0x6500000E` | turn-in-place left (**uses TurnRight anim in reverse**) |
| SideStepRight | `0x6500000F` | lateral movement |
| SideStepLeft | `0x65000010` | lateral movement (**uses SideStepRight in reverse**) |

### 3.3 Action commands (mask 0x10000000) — one-shot non-emote

Attacks, portals, skill uses. The client queues these then reverts to
whatever cycle was playing.

| Command | Hex |
|---------|-----|
| Hop | `0x1000004A` |
| Jumpup | `0x1000004B` |
| ChestBeat | `0x1000004D` |
| TippedLeft / TippedRight | `0x1000004E / 4F` |
| FallDown | `0x10000050` |
| Twitch1..4 | `0x10000051..54` |
| StaggerBackward / StaggerForward | `0x10000055..56` |
| **Sanctuary** | `0x10000057` (death trigger) |
| ThrustMed / Low / High | `0x10000058..5A` |
| SlashHigh / Med / Low | `0x1000005B..5D` |
| BackhandHigh / Med / Low | `0x1000005E..60` |
| Shoot | `0x10000061` (bow loose) |
| AttackHigh1..3 / Med1..3 / Low1..3 | `0x10000062..6A` |
| AttackHigh4..6 / Med4..6 / Low4..6 | `0x10000186..8E` (TOD expansion) |
| HeadThrow | `0x1000006B` (monster) |
| FistSlam | `0x1000006C` |
| BreatheFlame | `0x1000006D` |
| SpinAttack | `0x1000006E` |
| MagicPowerUp01..10 | `0x1000006F..78` (charging glyphs) |
| MagicPowerUp01Purple..10Purple | `0x1000012B..34` (void school) |
| EnterGame / ExitGame | `0x1000009C / 9D` |
| OnCreation / OnDestruction | `0x1000009E / 9F` |
| EnterPortal / ExitPortal | `0x100000A0 / A1` |
| SpecialAttack1..3 | `0x100000CD..CF` |
| MissileAttack1..3 | `0x100000D0..D2` |
| Blink | `0x100000E2` (teleport flash) |
| Bite | `0x100000E3` |
| SkillHealSelf / SkillHealOther | `0x1000010E / 10F` |
| LogOut | `0x1000011E` |
| DoubleSlashLow..High + TripleSlash... | `0x1000011F..24` |
| DoubleThrust... / TripleThrust... | `0x10000125..2A` |
| HouseRecall | `0x1000013A` |
| LifestoneRecall | `0x10000153` |
| Fishing | `0x10000165` |
| MarketplaceRecall | `0x10000166` |
| EnterPKLite | `0x10000167` |
| AllegianceHometownRecall | `0x10000171` |
| PKArenaRecall | `0x10000172` |
| OffhandSlashHigh..Low | `0x10000173..75` (dual wield) |
| OffhandThrustHigh..Low | `0x10000176..78` |
| OffhandDoubleSlash... / TripleSlash... | `0x10000179..7E` |
| OffhandDoubleThrust... / TripleThrust... | `0x1000017F..84` |
| OffhandKick | `0x10000185` |
| PunchFastHigh..Low / PunchSlowHigh..Low | `0x1000018F..94` |
| OffhandPunchFastHigh..Low etc. | `0x10000195..9A` |

### 3.4 ChatEmote commands (mask 0x02000000 — **but stored with 0x12 / 0x13 class prefix**)

The high nibble of emotes is `0x13` (Style mask **off**, ChatEmote mask
on). ACE calls these "ChatEmote-class", and retail blocks them in
combat stances (see §9).

| Command | Hex |
|---------|-----|
| YMCA | `0x1200009B` |
| Flatulence | `0x120000D4` |
| Demonet | `0x120000DF` |
| Cheer | `0x1300004C` |
| ShakeFist | `0x13000079` |
| Beckon | `0x1300007A` |
| BeSeeingYou | `0x1300007B` |
| BlowKiss | `0x1300007C` |
| BowDeep | `0x1300007D` |
| ClapHands | `0x1300007E` |
| Cry | `0x1300007F` |
| Laugh | `0x13000080` |
| MimeEat / MimeDrink | `0x13000081..82` |
| Nod | `0x13000083` |
| Point | `0x13000084` |
| ShakeHead | `0x13000085` |
| Shrug | `0x13000086` |
| Wave | `0x13000087` |
| Akimbo | `0x13000088` |
| HeartyLaugh | `0x13000089` |
| Salute | `0x1300008A` |
| ScratchHead | `0x1300008B` |
| SmackHead | `0x1300008C` |
| TapFoot | `0x1300008D` |
| WaveHigh / WaveLow | `0x1300008E..8F` |
| YawnStretch | `0x13000090` |
| Cringe | `0x13000091` |
| Kneel | `0x13000092` |
| Plead | `0x13000093` |
| Shiver | `0x13000094` |
| Shoo | `0x13000095` |
| Slouch | `0x13000096` |
| Spit | `0x13000097` |
| Surrender | `0x13000098` |
| Woah | `0x13000099` |
| Winded | `0x1300009A` |
| Pray | `0x130000CA` |
| Mock | `0x130000CB` |
| Teapot | `0x130000CC` |
| WarmHands | `0x13000119` |
| Helper | `0x13000135` |
| NudgeLeft / NudgeRight | `0x1300014A..4B` |
| PointLeft / PointRight / PointDown | `0x1300014C..4E` |
| Knock | `0x1300014F` |
| ScanHorizon | `0x13000150` |
| DrudgeDance | `0x13000151` |
| HaveASeat | `0x13000152` |

### 3.5 Persistent "State" emotes (class 0x43 = Style bit + SubState bit)

These are the **looping** emote states, not one-shots. They go through the
cycle path, not the action path.

```
ShakeFistState..WindedState | 0x430000EA..FD
SnowAngelState              | 0x43000118
CurtseyState                | 0x4300011A
AFKState                    | 0x4300011B
MeditateState               | 0x4300011C
SitState..AtEaseState       | 0x4300013D..49
```

### 3.6 Jump and meta-commands

```
StopTurning  = 0x2000003A  (Modifier)
Jump         = 0x2500003B  (Modifier, "0x25" = SubState | Modifier | HoldKey bit)
```

`Jump` itself is handled specially through `CMotionInterp::jump` (see §12).

---

## 4 — Stance transitions

Transitioning from NonCombat → SwordCombat (or any two styles) triggers
a **two-phase blend**:

1. **Phase 1:** play the `get_link(currentStyle, currentSubstate → DefaultStyle)`
   transition — this unwinds the current style (e.g. "put away sword").
2. **Phase 2:** play the `get_link(DefaultStyle, DefaultStyle's substate → newStyle)`
   transition — this brings up the new style (e.g. "draw greatsword").
3. **Phase 3:** enter the new style's default cycle.

This is the expanded shape of `MotionTable.GetObjectSequence` (ACE
`:76-119`):

```csharp
if ((motion & CommandMask.Style) != 0) {
    if (currState.Style == motion) return true;   // no-op
    // Phase 1: from-link inside old style
    if (substate != currState.Substate)
        motionData = get_link(currState.Style, currState.Substate, SubstateMod, substate, speedMod);
    // Phase 2+3: set-up for new style
    if (substate != 0) {
        Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);
        if (cycles != null) {
            if ((cycles.Bitfield & 1) != 0) currState.clear_modifiers();
            var link = get_link(currState.Style, substate, SubstateMod, motion, speedMod);
            if (link == null && currState.Style != motion) {
                link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f);
                motionData_ = get_link(DefaultStyle, defaultStyleSub, 1.0f, motion, 1.0f);
            }
            sequence.clear_physics();
            sequence.remove_cyclic_anims();
            add_motion(sequence, motionData, speedMod);  // unwind link
            add_motion(sequence, link, speedMod);        // connector link
            add_motion(sequence, motionData_, speedMod); // new-style intro
            add_motion(sequence, cycles, speedMod);      // new cycle
        }
    }
}
```

**Stance-transition timing.** Each link animation has its own duration
computed by `GetAnimationLength(animData) = (highFrame - lowFrame) /
abs(framerate)`. Cross-style transitions typically chain 3–4 links with
total duration 400 ms (NonCombat→Sword) to 1.8 s (HandCombat→TwoHandedSword
with big weapon swap). There is **no fixed blend time**; retail uses
framerate-driven timing on each AnimData.

---

## 5 — Cycle-internal motion events (animation hooks)

### 5.1 Per-frame hook list

`AnimationFrame.Hooks` is a `List<AnimationHook>` stored after the
per-part transforms. Each frame can carry zero or many hooks. The full
`AnimationHookType` enum (`AnimationHookType.generated.cs:13–71`):

| Value | HookType | Payload | Fires |
|------:|----------|---------|-------|
| `0x01` | **Sound** | `SoundHook { QualifiedDataId<Wave> Id }` | Plays wave asset (footstep, armor rustle, voice) |
| `0x02` | SoundTable | `SoundTableHook { SoundType type }` | Plays stance-appropriate sound via cookbook |
| `0x03` | **Attack** | `AttackHook { AttackCone cone }` | Damage frame for melee/thrown (AttackCone has direction + height) |
| `0x04` | AnimationDone | none | Signals sequencer to move on (queued by update_internal itself when crossing cycle boundary) |
| `0x05` | ReplaceObject | `ReplaceObjectHook { int part, uint newObjId }` | Swap a part's GfxObj (weapon draws, etc.) |
| `0x06` | Ethereal | `EtherealHook { int bool }` | Make object non-colliding |
| `0x07` | TransparentPart | `TransparentPartHook { int part, float alpha }` | Per-part alpha |
| `0x08` | Luminous | `LuminousHook { float level }` | Glow |
| `0x09` | LuminousPart | per-part glow |
| `0x0A` | Diffuse | `DiffuseHook { float r, g, b }` | Object tint |
| `0x0B` | DiffusePart | per-part tint |
| `0x0C` | Scale | `ScaleHook { float newScale, float lerpTime }` | Grow/shrink |
| `0x0D` | CreateParticle | `CreateParticleHook { uint scriptId, int part }` | Spawn particle emitter |
| `0x0E` | DestroyParticle | Despawn emitter |
| `0x0F` | StopParticle | Pause emitter |
| `0x10` | NoDraw | toggle rendering |
| `0x11` | DefaultScript | run default particle script |
| `0x12` | DefaultScriptPart | same, per part |
| `0x13` | CallPES | Invoke PES script by id |
| `0x14` | Transparent | whole-object alpha |
| `0x15` | SoundTweaked | Sound with pitch/volume override |
| `0x16` | SetOmega | Set mesh omega (for spinning parts) |
| `0x17` | TextureVelocity | Scrolling texture (water) |
| `0x18` | TextureVelocityPart | per-part |
| `0x19` | SetLight | Dynamic light on/off |
| `0x1A` | CreateBlockingParticle | Same as CreateParticle but blocks anim progression until done |

### 5.2 Firing semantics

Hooks have a `Direction` (`AnimationHookDir`): `Forward`, `Backward`, or
`Both`. The client fires a hook when the frame cursor *crosses* the
frame-boundary:

- **Forward playback** (framerate > 0): as `floor(framePos)` increments
  from `i` to `i+1`, hooks on frame `i` with dir `Forward|Both` fire.
- **Backward playback** (framerate < 0): as `floor(framePos)` decrements
  from `i` to `i-1`, hooks on frame `i` with dir `Backward|Both` fire.

The hook list is accumulated on `PhysicsObj.add_anim_hook`, then drained
after the tick to dispatch side-effects (sound, damage, particle spawn).

This is why `WalkBackwards` (played as reversed WalkForward) fires
different hooks than WalkForward — the LEFT/RIGHT footstep sounds are
tagged `Forward` and `Backward` respectively so they're still semantically
correct when played backward.

### 5.3 Where attack damage actually lands

`MotionTable.GetAttackFrames` (DatLoader `:87`) is the canonical way to
find the "hit frame" of an attack:

```csharp
foreach (anim in motionData.Anims)
  foreach (frame in anim.PartFrames)
    foreach (hook in frame.Hooks)
      if (hook is AttackHook ah)
        frames.Add(totalFrames, ah);
    totalFrames++;
// return [(time_percent = frameNum / totalFrames, attackHook), ...]
```

The server uses this to know *when* during the playback to roll damage —
the client fires an `Attack` hook at that frame, the server independently
computes the same timing and waits before applying damage.

`AttackCone` carries:
- A direction (which arm / weapon hand)
- A height (high/med/low)
- An optional offset (for reach)

So a dual-wield triple-slash produces **6 Attack hooks** across the
animation, each with its own cone — that's how one animation deals
multiple hits.

---

## 6 — Blend times and frame epsilons

**Retail does NOT use a fixed crossfade duration.** Interpolation
happens at two levels:

### 6.1 Within-animation interpolation

Between two adjacent AnimationFrames of the *same* AnimNode, the sequencer
slerps/lerps based on the *fractional part* of `_framePosition` (our port's
`AnimationSequencer.cs:619-620`). This is the standard "keyframes at
30Hz but render at 60Hz" smoothing. The fractional `t` is:
```
t = framePosition - floor(framePosition)   // always in [0,1)
```

### 6.2 Between-animation transitions

There is **no crossfade** between the old cycle and the new link
animation. The retail sequencer's `advance_to_next_animation`
(`FUN_00525EB0`) just hops:

```c
framePos = nextNode.GetStartFramePosition();  // hard cut
// Apply pos_frame delta to AFrame if PosFrames present
// Zero time spent interpolating between last-frame-of-old-node and
// first-frame-of-new-node
```

The *link animation itself* serves as the crossfade — designers built
short (5–15 frame) transitions whose job is to smoothly bring the
skeleton from "cycle A pose" to "cycle B pose". So the effective blend
time is the duration of the link MotionData, typically 150–400 ms.

### 6.3 Frame epsilon

The decompile repeatedly uses a tiny constant `_DAT_007c92b4` when
positioning the cursor at a frame boundary:

```c
if (framerate >= 0)
    startPos = (double)startFrame;
else
    startPos = (double)(endFrame + 1) - EPSILON;   // just under end+1
```

This matches our port's `FrameEpsilon = 1e-5`. The purpose is to ensure
that `floor(framePos)` is never equal to the next frame on initialization
— the cursor always starts "just before" its transition boundary.

The C `_DAT_007c92b4` value is `1.00000000000000082e-5` (double
precision). Our `1e-5` match is bit-identical for all practical
simulation rates.

### 6.4 Rate epsilon

`_DAT_007c9264` = the minimum-framerate cutoff = `1e-6`. Below this the
sequencer treats the animation as frozen and doesn't apply
pos_frame/physics deltas. Our port uses `RateEpsilon = 1e-6`.

---

## 7 — Animation frame data + part transforms

### 7.1 Shape

```
Animation {
    uint32 id (DBObj header)
    uint32 flags (AnimationFlags: PosFrames=0x1)
    uint32 numParts                    ; # of skeleton bones
    uint32 numFrames
    if (flags & PosFrames)
        Frame[numFrames]  posFrames    ; root motion: origin+orientation delta per frame
    AnimationFrame[numFrames]          ; each has numParts Frames + hook list
}

Frame {
    Vector3    origin
    Quaternion orientation             ; x,y,z,w order (IDENTITY = 0,0,0,1)
}

AnimationFrame {
    Frame[numParts]    perPartTransform
    uint32             numHooks
    AnimationHook[numHooks]
}
```

`PosFrames` — when present, they define **root motion**. The first
part's root slides/rotates frame-to-frame, and this delta is accumulated
into the `AFrame` (object placement). This is what actually moves the
character forward during WalkForward — the animation bakes in the
displacement.

### 7.2 How the cursor advances time

The sequencer's `update_internal` (ACE `Sequence.cs:351-443`, decompile
`FUN_005261D0`):

```
frametime = framerate * dt        // e.g. 30fps * 0.016s = 0.48 frames
lastFrame = floor(framePos)       // where we were last tick
framePos += frametime              // advance cursor

if (frametime > 0):
    if (highFrame < floor(framePos)):    // overshoot boundary
        frameOffset = framePos - highFrame - 1     // how far past end
        frameTimeElapsed = frameOffset / framerate // left-over time
        framePos = highFrame
        animDone = true
    while (floor(framePos) > lastFrame):
        if (posFrames present): apply posFrame delta to AFrame
        if (|framerate| > EPSILON): apply_physics(AFrame, 1/framerate, dt)
        execute_hooks(partFrames[lastFrame], Forward)
        lastFrame++
```

Frame advance sequence per tick:
1. Compute `newFramePos = oldFramePos + framerate*dt`
2. Walk every integer boundary we crossed (could be multiple if dt large)
3. For each boundary: add posFrame delta + omega/velocity delta + hooks
4. If we overshot the node end, advance to next node with remainder time

This is a **time-scaled integration**, not framerate-scaled. The actual
render framerate has no effect on animation speed. `framerate` (in
frames/sec) controls how fast the cursor moves.

### 7.3 The left→right / forward→backward remap

AC's MotionTable has **no cycles** for TurnLeft, SideStepLeft, or
WalkBackwards. These are remapped in `CMotionInterp::adjust_motion`
(ACE `MotionInterp.cs:394-428`, decompile `FUN_00528C20`):

```csharp
switch (motion) {
    case RunForward:    return;                        // noop
    case WalkBackwards:
        motion = WalkForward;
        speed *= -0.65f;                               // BackwardsFactor
        break;
    case TurnLeft:
        motion = TurnRight;
        speed *= -1.0f;
        break;
    case SideStepLeft:
        motion = SideStepRight;
        speed *= -1.0f;
        break;
}
if (motion == SideStepRight)
    speed *= 0.5f * (3.12f / 1.25f);                   // SidestepFactor correction

if (holdKey == HoldKey.Run)
    apply_run_to_command(ref motion, ref speed);       // §8
```

The speed sign-flip propagates through `multiply_framerate` (decompile
`FUN_005267E0`): any negative framerate swaps `startFrame ↔ endFrame`
so the advance logic works uniformly:

```c
void multiply_framerate(AnimNode* node, float factor) {
    if (factor < 0.0) {
        // swap startFrame and endFrame
        int tmp = node.startFrame;
        node.startFrame = node.endFrame;
        node.endFrame = tmp;
    }
    node.framerate *= factor;
}
```

### 7.4 Quaternion interpolation (retail slerp)

The decompiled `FUN_005360d0` implements slerp with a special degenerate
fallback:

```c
float dot = dot(q1, q2);
if (dot < 0) { q2 = -q2; dot = -dot; }     // shorter arc
if (1-dot <= EPSILON) {                      // near-parallel
    w1 = 1-t; w2 = t;                        // linear fallback
} else {
    omega = acos(dot); sinOmega = sin(omega);
    w1 = sin((1-t)*omega)/sinOmega;
    w2 = sin(t*omega)/sinOmega;
    // Retail-specific: validate results in [0,1]
    if (w1 not in [0,1] || w2 not in [0,1]) {
        w1 = 1-t; w2 = t;                    // bail to linear
    }
}
result = w1*q1 + w2*q2
```

Our port has this — no changes needed.

---

## 8 — Speed modifiers

### 8.1 The speed chain

Speed flows through many multipliers before reaching the body:

```
1. Network: client packs RawMotionState.ForwardSpeed (1.0 for walk, 1.5-2x for run with HoldKey)
2. DoMotion: adjust_motion applies:
   - BackwardsFactor (-0.65) if WalkBackwards
   - Sign flip if TurnLeft/SideStepLeft
   - SidestepFactor (0.5 * 3.12/1.25 = 1.248) if SideStepRight
   - If HoldKey.Run: walk→run conversion + run-rate multiplier
3. MotionTable.add_motion: velocity = MotionData.Velocity * speedMod
4. Sequence advance: framerate = AnimData.Framerate * speedMod
5. PhysicsBody.set_local_velocity: clamp to min(velocity, RunAnimSpeed * MyRunRate)
```

Two constants govern the clamps:

| Constant | ACE value | Retail DAT | Purpose |
|----------|----------:|-----------:|---------|
| `WalkAnimSpeed` | `3.12f` | `_DAT_007c96e4` | Meters/sec of a 1.0-speed WalkForward |
| `RunAnimSpeed` | `4.0f` | `_DAT_007c96e0` | Meters/sec of a 1.0-speed RunForward |
| `SidestepAnimSpeed` | `1.25f` | `_DAT_007c96e8` | Meters/sec of a 1.0-speed SideStepRight |
| `BackwardsFactor` | `-0.65f` | `_DAT_007c96d8` | Walk-backwards is 65% as fast as forward |
| `SidestepFactor` | `0.5f` | — | Sidestep defaults to half-speed |
| `MaxSidestepAnimRate` | `3.0f` | `_DAT_007c96ec` | Clamp on sidestep speed |
| `RunTurnFactor` | `1.5f` | `_DAT_007c96dc` | Hold-run multiplies turn speed by 1.5 |

### 8.2 Run-rate (stamina-weighted)

Players have a **RunRate** (0.5–2.0) based on Run skill + Quickness.
`WeenieObject.InqRunRate(out rate)` returns it. The client caches the
last-reported value in `MyRunRate` (offset `+0x7C`) so it can continue
computing speeds if the Weenie is momentarily unreachable.

```csharp
// get_state_velocity (FUN_00528960):
velocity = Vector3.Zero;
if (SideStepCommand == SideStepRight)
    velocity.X = SidestepAnimSpeed * SideStepSpeed;
if (ForwardCommand == WalkForward)
    velocity.Y = WalkAnimSpeed * ForwardSpeed;
else if (ForwardCommand == RunForward)
    velocity.Y = RunAnimSpeed * ForwardSpeed;

rate = WeenieObj.InqRunRate(out _) ? queriedRate : MyRunRate;
maxSpeed = RunAnimSpeed * rate;      // typically 4.0 to 8.0 m/s
if (|velocity| > maxSpeed)
    velocity = normalize(velocity) * maxSpeed;   // clamp diagonal
```

**Key insight:** the clamp is on Euclidean length, not per-axis. This
is why retail forward+sidestep diagonal isn't faster than pure forward
at max run rate.

### 8.3 Framerate vs time scaling

Both animation framerate and physics velocity scale linearly with
`speedMod`. So running at 2.0× rate:
- Framerate = `baseFramerate * 2.0` → animation plays 2× fast
- Velocity = `baseVelocity * 2.0` → body moves 2× fast
- Result: feet plant at correct spots (no "ice-skating")

This is **time-scaled, not framerate-scaled**. The animation playback
in game loop ticks advances by `framerate * dt` each frame regardless
of render framerate.

---

## 9 — Motion errors (WeenieError)

From `WeenieError.cs` L148–L193:

| Error | Value | Thrown by | Cause |
|-------|------:|-----------|-------|
| `None` | `0x00` | — | Success |
| `NoPhysicsObject` | `0x08` | all CMotionInterp entry points | Object detached / deallocated |
| `NoAnimationTable` | — | MotionTableManager.PerformMovement | MotionTable wasn't loaded |
| `NoMtableData` | `0x0043` | MotionTableManager | Cycle/link not found in table |
| `CantCrouchInCombat` | `0x003F` | DoMotion when in non-NonCombat style | Crouch attempted during combat |
| `CantSitInCombat` | `0x0040` | DoMotion, similar | Sit attempted |
| `CantLieDownInCombat` | `0x0041` | DoMotion | Sleep attempted |
| `CantChatEmoteInCombat` | `0x0042` | DoMotion (`motion & ChatEmote != 0`) | Wave/etc. in combat |
| `CantChatEmoteNotStanding` | `0x0044` | DoMotion indirectly | Emote while sitting/crouched |
| `TooManyActions` | `0x0045` | DoMotion action-path (>6 queued) | Action queue overflow |
| `Hidden` | `0x0046` | (not motion-owned) | Misc |
| `GeneralMovementFailure` | `0x0047` | Fall-through default | Switch default branch |
| `YouCantJumpFromThisPosition` | `0x0048` | motion_allows_jump, jump_is_allowed | Crouching, reading, casting, etc. |
| `CantJumpLoadedDown` | `0x0049` | jump_is_allowed | WeenieObj.CanJump returned false |

Additional **action-state errors** from DoInterpretedMotion (decompile
`FUN_00528f70` L6967–L7005):

| Code | Motion checked | Meaning |
|-----:|---------------|---------|
| `0x3F` | Crouch (`0x41000012`) while in combat | Same as CantCrouchInCombat |
| `0x40` | Sitting (`0x41000013`) while in combat | Same as CantSitInCombat |
| `0x41` | Sleeping (`0x41000014`) while in combat | Same as CantLieDownInCombat |
| `0x42` | ChatEmote bit (`0x02000000`) set while in combat | Same as CantChatEmoteInCombat |
| `0x45` | ActionMask bit (`0x10000000`) set and `num_actions >= 6` | Action queue full |

The action queue limit of **6** is a retail invariant — see
`FUN_00529930:7614 if (5 < uVar3) return 0x45`. This means you can
line up a 3-slash combo + cast start, but a 7th action errors.

---

## 10 — Network motion echoes (UpdateMotion)

### 10.1 Wire shape of `GameMessageUpdateMotion` (opcode 0xF748 / Motion)

Sent by server whenever any object's motion state changes. From
ACE `GameMessageUpdateMotion.cs` + `MovementData.cs` + `InterpretedMotionState.cs`:

```
GameMessage {
    u32 opcode = 0xF748                          ; Motion group=SmartboxQueue
    ObjectGuid guid                               ; 4 bytes
    u16 objectInstanceSequence                    ; ordering counter
    u16 movementSequence                          ; incremented every Motion update
    u16 serverControlSequence                     ; if IsAutonomous, use current; else next
    u8  isAutonomous                              ; 1=client requested, 0=server-pushed
    align(4)
    u8  movementType                              ; enum: 0=Invalid..9=TurnToHeading
    u8  motionFlags                               ; 0x01=StickToObject, 0x02=StandingLongJump
    u16 currentStyle                              ; truncated to 16 bits (0x003D for NonCombat)

    switch (movementType) {
      case Invalid: InterpretedMotionState state
                    if (motionFlags & 0x01) ObjectGuid stickyObject
      case MoveToObject:    ObjectGuid target; Origin origin; MoveToParameters; f32 runRate
      case MoveToPosition:  Origin; MoveToParameters; f32 runRate
      case TurnToObject:    ObjectGuid target; f32 desiredHeading; TurnToParameters
      case TurnToHeading:   TurnToParameters
    }
}

InterpretedMotionState {
    u32 packedFlagsAndCount     ; low 7 bits = MovementStateFlag, upper 25 = numCommands
    if (flag & CurrentStyle)    u16 currentStyle
    if (flag & ForwardCommand)  u16 forwardCommand       ; low 16 bits of MotionCommand
    if (flag & SideStepCommand) u16 sidestepCommand
    if (flag & TurnCommand)     u16 turnCommand
    if (flag & ForwardSpeed)    f32 forwardSpeed
    if (flag & SideStepSpeed)   f32 sidestepSpeed
    if (flag & TurnSpeed)       f32 turnSpeed
    MotionItem[numCommands]     ; each is 8 bytes: u16 cmd + u16 packedSeq + f32 speed
    align(4)
}

MotionItem {                    ; one entry in the "Commands" queue
    u16 command
    u16 packedSequence           ; bit 15: isAutonomous, bits 0-14: sequence
    f32 speed
}

MovementStateFlag {              ; 7 bits
    CurrentStyle   = 0x01
    ForwardCommand = 0x02
    ForwardSpeed   = 0x04
    SideStepCommand= 0x08
    SideStepSpeed  = 0x10
    TurnCommand    = 0x20
    TurnSpeed      = 0x40
}

Origin { u32 cellId; f32 x,y,z }
MoveToParameters { u32 flags; f32 distanceToObject, minDistance, failDistance, speed, walkRunThreshold, desiredHeading }
TurnToParameters { u32 flags; f32 speed, desiredHeading }
```

### 10.2 Client broadcasts vs local simulation

The client maintains a **RawMotionState** (what it *wants* to do —
driven by keypresses) and an **InterpretedMotionState** (what the
sequencer should *actually* play). The server receives
`MoveToState` packets carrying the raw state, interprets them into
`InterpretedMotionState` via `MovementData(Creature, MoveToState)`
(ACE `:87-165`), and broadcasts `UpdateMotion` to observers.

**Client simulation loop:**
1. Input thread sets `RawMotionState.ForwardCommand = WalkForward` + `HoldKey.Run`
2. `CMotionInterp::apply_raw_movement` calls `adjust_motion` → `WalkForward + Run` becomes `RunForward` with speed ×runRate
3. Animation sequencer starts the `(NonCombat, RunForward)` cycle
4. Physics body's local velocity = `RunAnimSpeed * runRate`
5. Client sends `0xF61B MoveToState` packet with the *raw* state
6. Server validates, emits `0xF748 UpdateMotion` with interpreted state to nearby observers
7. Observers' clients receive UpdateMotion, apply via `move_to_interpreted_state`

**Observation of own player:** When the server broadcasts *my own*
motion back to me, I compare the received sequence to my local one via
`action.Stamp` (15-bit ring counter at offset `+0x78`). If server is
ahead of me, I catch up; otherwise I ignore. See `FUN_005295D0` L7363–L7388.

### 10.3 The RawMotionState → InterpretedMotionState conversion

This is the **server's** job, but it's worth understanding:

```csharp
// MovementData.cs:87-165
var holdKey = rawState.CurrentHoldKey;
var speed = (holdKey == HoldKey.Run) ? creature.GetRunRate() : 1.0f;
if (rawState.ForwardCommand == WalkForward || WalkBackwards) {
    interp.ForwardCommand = (holdKey==Run && WalkForward) ? RunForward : WalkForward;
    interp.ForwardSpeed = speed;
    if (WalkBackwards) interp.ForwardSpeed *= -0.65f;
}
if (rawState.SidestepCommand != 0) {
    interp.SidestepCommand = SideStepRight;
    interp.SidestepSpeed = speed * (3.12/1.25) * 0.5 * (LeftSign);
    interp.SidestepSpeed = clamp(-3, 3);
}
if (rawState.TurnCommand != 0) {
    interp.TurnCommand = TurnRight;
    interp.TurnSpeed = (Run)?1.5:1.0 * TurnSign;
    // Mouselook override: if rawState.TurnSpeed <= 1.5 use it verbatim
}
```

---

## 11 — Audio-motion coupling

### 11.1 Footstep sounds

**Per-surface footsteps** are NOT encoded by the Sound hook directly —
they use `SoundTable` hooks (`AnimationHookType.SoundTable = 0x02`)
which carry a `SoundType` selector. The engine then looks up the
actual Wave id from the creature's `PhysicsObj.SoundTable`, keyed by
the surface material of the cell the creature is standing on.

So `WalkForward` has two `SoundTable` hooks at the foot-plant frames
(frames 5 and 15 of a ~20-frame cycle typically). The SoundType might
be `StepLeft` / `StepRight`, and the resolved wave depends on whether
you're on grass (dirt+leaves whoosh), stone (metallic clack), water
(splash), etc.

Surface-material lookup is outside R3 scope (lives in R9 terrain
system), but the attack/motion side is: **the hooks fire at fixed
frame indexes regardless of surface**, then the audio subsystem picks
the right wave.

### 11.2 Attack whoosh

Melee attacks have a `Sound` hook (not SoundTable) firing 2–3 frames
*before* the `Attack` hook — i.e. the wind-up whoosh precedes the
damage frame. The same Wave id is used regardless of weapon (it's
material-agnostic "swoosh"). Weapon-specific sounds (metal clang on
hit) fire from the server side via `GameMessageHearSound` after
damage resolves.

### 11.3 Armor rustle

An optional layer driven by `SoundTable` hooks on *every* animation
frame cycle where `RawMotionFlags.ArmorEncumbrance` was set on the
body. This is throttled client-side so you don't hear 60Hz rustling.
There is no per-frame hook for this; it's a PhysicsObj post-tick
callback.

---

## 12 — Complete pseudocode for `DoInterpretedMotion` and friends

### 12.1 DoInterpretedMotion (FUN_00528F70) — expanded

```
int DoInterpretedMotion(CMotionInterp* self, uint motion, MovementParameters* params) {
    if (!self->physicsObj) return 0x08;      // NoPhysicsObject
    int allowed = contact_allows_move(motion);
    if (!allowed) {
        if (motion & 0x10000000)             // Action bit
            return 0x24;                     // YouCantJumpWhileInTheAir (0x48 in ACE)
        // Non-action motion is silently accepted (state updates, no anim)
    } else if (self->standingLongJump && (motion==WalkForward || motion==RunForward || motion==SideStepRight)) {
        // No-op: standing long jump blocks all ground motion
    } else {
        if (motion == Dead) self->physicsObj->RemoveLinkAnimations();
        int result = self->physicsObj->DoInterpretedMotion(motion, params);
        if (result == 0) {
            int jumpErr;
            if (params->flags & 0x20000)     // DisableJumpDuringLink
                jumpErr = 0x48;              // YouCantJumpFromThisPosition
            else {
                jumpErr = motion_allows_jump(motion);
                if (jumpErr == 0 && !(motion & 0x10000000))
                    jumpErr = motion_allows_jump(self->interpretedState.ForwardCommand);
            }
            add_to_queue(params->contextId, motion, jumpErr);
            if (params->flags & 0x4000)      // ModifyInterpretedState
                InterpretedState_ApplyMotion(&self->interpretedState, motion, params);
        }
    }
    if (self->physicsObj && self->physicsObj->curCell == NULL)
        self->physicsObj->RemoveLinkAnimations();
    return result;
}
```

### 12.2 adjust_motion (FUN_00528C20) — left/right/backward remap

```
void adjust_motion(uint* motion, float* speed, int holdKey) {
    if (self->weenieObj != NULL && !self->weenieObj->IsCreature()) return;
    switch (*motion) {
        case 0x44000007: return;             // RunForward: no-op
        case 0x45000006:                     // WalkBackwards → WalkForward reversed
            *motion = 0x45000005; *speed *= -0.65f; break;
        case 0x6500000E:                     // TurnLeft → TurnRight reversed
            *motion = 0x6500000D; *speed *= -1.0f; break;
        case 0x65000010:                     // SideStepLeft → SideStepRight reversed
            *motion = 0x6500000F; *speed *= -1.0f; break;
    }
    if (*motion == 0x6500000F)               // SideStepRight: apply sidestep factor
        *speed *= 0.5f * (3.12f / 1.25f);    // = 1.248
    if (holdKey == HoldKey.Invalid)
        holdKey = self->rawState.currentHoldKey;
    if (holdKey == HoldKey.Run)
        apply_run_to_command(motion, speed);
}
```

### 12.3 apply_run_to_command (FUN_005287F0)

```
void apply_run_to_command(uint* motion, float* speed) {
    float rate = DEFAULT_RUN_RATE;
    if (self->weenieObj) {
        float r;
        if (self->weenieObj->InqRunRate(&r)) rate = r;
        else rate = self->myRunRate;
    }
    switch (*motion) {
        case 0x45000005:                     // WalkForward + Run = RunForward
            if (*speed > 0) *motion = 0x44000007;
            *speed *= rate; break;
        case 0x6500000D:                     // TurnRight + Run
            *speed *= 1.5f;                  // RunTurnFactor
            break;
        case 0x6500000F:                     // SideStepRight + Run
            *speed *= rate;
            if (|*speed| > 3.0f) *speed = sign(*speed) * 3.0f;
            break;
    }
}
```

### 12.4 PerformMovement (FUN_00529A90) top-level

```
int PerformMovement(CMotionInterp* self, MovementStruct* mvs) {
    int result;
    switch (mvs->type) {
        case 1: result = DoMotion(mvs->motion, &mvs->params);            break;
        case 2: result = DoInterpretedMotion(mvs->motion, &mvs->params); break;
        case 3: result = StopMotion(mvs->motion, &mvs->params);           break;
        case 4: result = StopInterpretedMotion(mvs->motion, &mvs->params);break;
        case 5: result = StopCompletely();                                break;
        default: return 0x47;                // GeneralMovementFailure
    }
    self->physicsObj->CheckForCompletedMotions();  // drain PendingMotions queue
    return result;
}
```

### 12.5 GetObjectSequence — the motion table walker

```
bool GetObjectSequence(MotionTable* mt, uint motion, MotionState* state,
                      Sequence* seq, float speedMod, uint* numAnims, bool stopMods) {
    *numAnims = 0;
    if (state->style == 0 || state->substate == 0) return false;

    uint styleDefault = mt->styleDefaults[state->style];
    if (motion == styleDefault && !stopMods && (state->substate & Modifier)) return true;

    // === STYLE CHANGE PATH ===
    if (motion & Style) {
        if (state->style == motion) return true;
        MotionData* fromLink = NULL;
        if (styleDefault != state->substate)
            fromLink = get_link(state->style, state->substate, state->substateMod, styleDefault, speedMod);
        if (styleDefault != 0) {
            MotionData* cycles = mt->cycles[(motion<<16)|styleDefault];
            if (cycles) {
                if (cycles->bitfield & 1) state->clear_modifiers();
                MotionData* connectorLink = get_link(state->style, styleDefault, state->substateMod, motion, speedMod);
                MotionData* secondLink = NULL;
                if (!connectorLink && state->style != motion) {
                    connectorLink = get_link(state->style, styleDefault, 1.0f, mt->defaultStyle, 1.0f);
                    secondLink = get_link(mt->defaultStyle, mt->styleDefaults[mt->defaultStyle], 1.0f, motion, 1.0f);
                }
                seq->clear_physics();
                seq->remove_cyclic_anims();
                add_motion(seq, fromLink, speedMod);
                add_motion(seq, connectorLink, speedMod);
                add_motion(seq, secondLink, speedMod);
                add_motion(seq, cycles, speedMod);
                state->substate = styleDefault;
                state->style = motion;
                state->substateMod = speedMod;
                re_modify(seq, state);
                *numAnims = totalAnimsAcrossAllFour - 1;
                return true;
            }
        }
    }

    // === SUBSTATE CHANGE PATH (Walk → Run etc.) ===
    if (motion & SubState) {
        uint motionID = motion & 0xFFFFFF;
        MotionData* target = mt->cycles[(state->style<<16)|motionID];
        if (!target) target = mt->cycles[(mt->defaultStyle<<16)|motionID];
        if (target && is_allowed(motion, target, state)) {
            if (motion == state->substate && seq->HasAnims() &&
                sign(speedMod) == sign(state->substateMod)) {
                // Same command, just tweak speed
                change_cycle_speed(seq, target, state->substateMod, speedMod);
                subtract_motion(seq, target, state->substateMod);
                combine_motion(seq, target, speedMod);
                state->substateMod = speedMod;
                return true;
            }
            if (target->bitfield & 1) state->clear_modifiers();
            MotionData* link = get_link(state->style, state->substate, state->substateMod, motion, speedMod);
            MotionData* altLink = NULL;
            if (!link || sign(speedMod)!=sign(state->substateMod)) {
                uint def = mt->styleDefaults[state->style];
                link = get_link(state->style, state->substate, state->substateMod, def, 1.0f);
                altLink = get_link(state->style, def, 1.0f, motion, speedMod);
            }
            seq->clear_physics();
            seq->remove_cyclic_anims();
            if (altLink) {
                add_motion(seq, link, state->substateMod);
                add_motion(seq, altLink, speedMod);
            } else {
                float ns = (state->substateMod < 0 && speedMod > 0) ? -speedMod : speedMod;
                add_motion(seq, link, ns);
            }
            add_motion(seq, target, speedMod);
            if (state->substate != motion && (state->substate & Modifier)) {
                uint def = mt->styleDefaults[state->style];
                if (def != motion) state->add_modifier_no_check(state->substate, state->substateMod);
            }
            state->substateMod = speedMod;
            state->substate = motion;
            re_modify(seq, state);
            return true;
        }
    }

    // === ACTION PATH (one-shot) ===
    if (motion & Action) {
        uint cycleKey = (state->style<<16) | (state->substate & 0xFFFFFF);
        MotionData* cycles = mt->cycles[cycleKey];
        if (cycles) {
            MotionData* link = get_link(state->style, state->substate, state->substateMod, motion, speedMod);
            if (link) {
                state->add_action(motion, speedMod);
                seq->clear_physics();
                seq->remove_cyclic_anims();
                add_motion(seq, link, speedMod);
                add_motion(seq, cycles, state->substateMod);
                re_modify(seq, state);
                return true;
            }
            // ... fallback via defaultStyleSub path (lines 209-230 of MotionTable.cs)
        }
    }

    // === MODIFIER PATH (overlay) ===
    if (motion & Modifier) {
        MotionData* base = mt->cycles[(state->style<<16) | (state->substate & 0xFFFFFF)];
        if (base && !(base->bitfield & 1)) {
            MotionData* mod = mt->modifiers[(state->style<<16)|(motion & 0xFFFFFF)];
            if (!mod) mod = mt->modifiers[motion & 0xFFFFFF];   // style-agnostic fallback
            if (mod) {
                if (!state->add_modifier(motion, speedMod)) {
                    StopSequenceMotion(motion, 1.0f, state, seq, numAnims);
                    if (!state->add_modifier(motion, speedMod)) return false;
                }
                combine_motion(seq, mod, speedMod);              // ADD velocities, don't replace
                return true;
            }
        }
    }
    return false;
}
```

---

## 13 — Port plan (extensions to MotionInterpreter / AnimationSequencer)

### 13.1 Gaps vs current port

Current `MotionInterpreter` is ~660 LOC and covers:
- RawMotionState, InterpretedMotionState structs ✅
- PerformMovement switch-5 ✅
- DoMotion/DoInterpretedMotion (partial — no CantCrouch etc.) ✅
- StopMotion/StopInterpretedMotion/StopCompletely ✅
- get_state_velocity with max-speed clamp ✅
- apply_current_movement (grounded only) ✅
- Jump chain (jump, jump_is_allowed, get_leave_ground_velocity, LeaveGround, HitGround) ✅

Current `AnimationSequencer` covers:
- SetCycle with left→right remap ✅
- Enqueue link + cyclic nodes, advance time, wrap to firstCyclic ✅
- Retail slerp ✅
- BUT: treats MotionData as mono-cycle. No style-transition chain, no
  modifiers, no actions, no attack hooks, no sound hooks.

### 13.2 Extensions required for R3

**`MotionInterpreter.cs` additions:**

1. `public MotionState MotionState` (style/substate/substateMod + modifier list + action list)
2. `public enum HoldKey { Invalid, None, Run }` + `RawState.CurrentHoldKey` field
3. `CombatStyleGuards` in `DoMotion`:
   ```csharp
   if (InterpretedState.CurrentStyle != NonCombat) {
     switch(motion) {
       case Crouch: return CantCrouchInCombat;
       case Sitting: return CantSitInCombat;
       case Sleeping: return CantLieDownInCombat;
     }
     if ((motion & ChatEmote) != 0) return CantChatEmoteInCombat;
   }
   ```
4. `ActionQueueLimit`: track `numActions`; throw `TooManyActions` at 6.
5. `adjust_motion` full port (currently only in AnimationSequencer.SetCycle — pull it up into MotionInterpreter so DoMotion/StopMotion both use it).
6. `apply_run_to_command` — handle HoldKey.Run + walk→run conversion + sidestep clamp.
7. `SetHoldKey(HoldKey k, bool cancelMoveTo)` + `set_hold_run(int, bool)`.
8. `motion_allows_jump(uint substate)` — the ranged-check helper:
   ```csharp
   // Reload..Pickup, TripleThrust..MagicPowerUp10Purple, MagicPowerUp01..10,
   // Crouch..Sleeping, AimLevel..MagicPray, Falling → YouCantJumpFromThisPosition
   ```
9. `move_to_interpreted_state(InterpretedMotionState state)` — receive
   and replay the server's authoritative state, including action-stamp
   comparison logic from `FUN_005295D0`.
10. `enter_default_state()` and `HandleEnterWorld()/HandleExitWorld()`.

**`AnimationSequencer.cs` additions:**

1. **`EnqueueStyleTransition(uint oldStyle, uint oldSub, uint newStyle, float speed)`** — the multi-link chain from §4.
2. **`AddModifier(uint modCommand, float speed)` / `RemoveModifier`** — maintain the overlay list. When a modifier is active, its `MotionData.Velocity` and `Omega` are *added* to the base cycle's (not replaced). See `CombinePhysics` / `subtract_physics` in ACE Sequence.cs.
3. **`AddAction(uint actionCommand, float speed)`** — queue a one-shot
   link+cycle pair. The action plays to completion then pops.
4. **Per-frame hook dispatch** — populate a `PendingHooks: List<AnimationHook>` each tick. Callers consume it via a new `IReadOnlyList<AnimationHook> ConsumePendingHooks()` method.
5. **`PosFrames` integration** — if the current Animation has PosFrames,
   apply the per-frame origin+orientation delta to a running AFrame
   (exposed as a `PartTransform` on "part -1" or a separate `RootMotion` property).
6. **Speed-change mid-cycle** — when the same command is re-issued with a
   different speed, don't restart; call `multiply_cyclic_animation_framerate(newSpeed/oldSpeed)` — see `MotionTable.change_cycle_speed`. Currently `SetCycle`'s fast-path returns early for same motion but doesn't handle speed changes.

**New classes:**

- `MotionState` — mirror of ACE's (Style, Substate, SubstateMod, Modifiers, Actions).
- `Motion` (sequence node): `{ uint ID; float SpeedMod }` for modifier/action tracking.
- `MotionTableWalker` — a new type that owns the GetObjectSequence logic, separate from both MotionInterpreter (which is physics-focused) and AnimationSequencer (which is time-stepper-focused). Mirror of ACE's `MotionTable` + `MotionTableManager`.
- `AttackHook`/`SoundHook`/`SoundTableHook` types (already in DatReaderWriter; just need our own switch dispatch).

**Wire layer (R10 adjacent but referenced here):**

- `MovementData` reader/writer matching ACE's layout byte-for-byte.
- `InterpretedMotionState` + `RawMotionState` + `MotionItem` codecs — holtburger has working Rust implementations to mirror.
- Server→client `0xF748 Motion` handler that constructs `MovementStruct { type=2, motion=interp.ForwardCommand, ... }` and calls `MotionInterpreter.PerformMovement`.

---

## 14 — Conformance test golden cases

1. **Cycle key for (NonCombat, WalkForward) = `0x003D0005`**
   ```csharp
   Assert.Equal(0x003D0005, MotionTable.MakeCycleKey(MotionStance.NonCombat, MotionCommand.WalkForward));
   ```

2. **Cycle key for (Magic, MeditateState) = `0x0049011C`**
   ```csharp
   Assert.Equal(0x0049011C, MotionTable.MakeCycleKey(MotionStance.Magic, MotionCommand.MeditateState));
   ```

3. **TurnLeft remaps to TurnRight with speed ×-1**
   ```csharp
   uint motion = (uint)MotionCommand.TurnLeft; float speed = 1.0f;
   interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
   Assert.Equal((uint)MotionCommand.TurnRight, motion);
   Assert.Equal(-1.0f, speed);
   ```

4. **WalkBackwards remaps to WalkForward ×-0.65**
   ```csharp
   uint motion = (uint)MotionCommand.WalkBackwards; float speed = 1.0f;
   interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
   Assert.Equal((uint)MotionCommand.WalkForward, motion);
   Assert.Equal(-0.65f, speed);
   ```

5. **Hold-Run + WalkForward becomes RunForward × runRate**
   ```csharp
   uint motion = (uint)MotionCommand.WalkForward; float speed = 1.0f;
   interp.MyRunRate = 1.5f;
   interp.AdjustMotion(ref motion, ref speed, HoldKey.Run);
   Assert.Equal((uint)MotionCommand.RunForward, motion);
   Assert.Equal(1.5f, speed, 1e-6);
   ```

6. **SideStepRight applies SidestepFactor**
   ```csharp
   uint motion = (uint)MotionCommand.SideStepRight; float speed = 1.0f;
   interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
   Assert.Equal(1.248f, speed, 1e-3);   // 0.5 * 3.12 / 1.25
   ```

7. **Seven actions queued → TooManyActions on 7th**
   ```csharp
   var interp = MakeInterp();
   for (int i = 0; i < 6; i++)
     Assert.Equal(WeenieError.None, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters()));
   Assert.Equal(WeenieError.TooManyActions, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters()));
   ```

8. **Crouch in SwordCombat fails**
   ```csharp
   interp.InterpretedState.CurrentStyle = (uint)MotionStance.SwordCombat;
   Assert.Equal(WeenieError.CantCrouchInCombat,
                interp.DoMotion(MotionCommand.Crouch, new MovementParameters()));
   ```

9. **Jump while airborne fails**
   ```csharp
   phys.TransientState &= ~TransientStateFlags.OnWalkable;
   Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, interp.Jump(1.0f, 0));
   ```

10. **Wave emote in NonCombat succeeds**
    ```csharp
    interp.InterpretedState.CurrentStyle = (uint)MotionStance.NonCombat;
    Assert.Equal(WeenieError.None,
                 interp.DoMotion(MotionCommand.Wave, new MovementParameters()));
    ```

11. **AnimationSequencer.SetCycle with speed=0 doesn't advance**
    ```csharp
    seq.SetCycle(NonCombat, RunForward, speedMod: 0f);
    var pre = seq.Advance(0.016f);
    var post = seq.Advance(0.016f);
    AssertAllTransformsEqual(pre, post);
    ```

12. **Negative framerate reverses playback** (golden from decompiled multiply_framerate):
    ```csharp
    var node = new AnimNode(anim, framerate: -30f, startFrame: 0, endFrame: 10, isLooping: true);
    // After multiply_framerate, cursor should start near endFrame+1 - ε
    Assert.True(node.GetStartFramePosition() > 10.0 - 1e-3);
    ```

13. **MotionData.Velocity scales linearly with speedMod**
    ```csharp
    var md = new MotionData { Velocity = new Vector3(0, 4, 0) };   // 4 m/s forward
    seq.AddMotion(md, speedMod: 0.5f);
    Assert.Equal(new Vector3(0, 2, 0), seq.Velocity);
    ```

14. **Attack hook fires exactly once per animation cycle**
    ```csharp
    seq.SetCycle(SwordCombat, SlashHigh, speedMod: 1f);
    var hitCount = 0;
    for (int i = 0; i < 100; i++) {
        seq.Advance(0.016f);
        hitCount += seq.ConsumePendingHooks().Count(h => h is AttackHook);
    }
    Assert.Equal(1, hitCount);
    ```

---

## 15 — Cross-cut notes

- **Thread-safety:** `MotionTable` is read-only after unpack; walking it
  is safe from any thread. `Sequence` and `MotionInterpreter` are
  per-entity and expected to run on the sim thread. The client uses
  a single dedicated thread for all motion updates (see chunk_00450000.c).

- **Memory layout sensitivity:** `CMotionInterp` struct offsets are
  cited throughout (`+0x20` ForwardCommand, `+0x4C` interp-ForwardCommand,
  `+0x70` StandingLongJump, `+0x74` JumpExtent, `+0x78` ActionStamp,
  `+0x7C` MyRunRate). Our C# port doesn't need to preserve these but
  the decompiled references do, and when debugging from raw memory
  dumps they're useful.

- **PendingMotions queue:** Each `DoInterpretedMotion` success adds a
  `MotionNode { contextID, motion, jumpErrorCode }` to the
  PendingMotions linked list. This list drains when the sequencer
  reports the animation has *fully* played (via `HookObj.add_anim_hook(AnimationDone)`).
  Until then, `physicsObj->IsAnimating = true` and `motions_pending() == true`.

- **Link-removal optimization:** `MotionTableManager.remove_redundant_links`
  scans the PendingAnimations list; when two consecutive entries have
  the same motion ID (e.g. double-tap of WalkForward), it truncates
  the older one by setting its NumAnims to 0 and calling
  `sequence.remove_link_animations(totalAnims)`. This is how
  rapid-fire key-mashing doesn't pile up.

---

## References cited

- `docs/research/decompiled/chunk_00520000.c`:
  L3080–L3117 (PerformMovement switch),
  L4368–L4677 (update_internal + advance_to_next_animation),
  L4698–L4784 (multiply_framerate, GetStart/EndFramePosition),
  L6433–L6504 (add_to_queue, apply_run_to_command),
  L6565–L6611 (get_state_velocity),
  L6616–L6641 (StopCompletely),
  L6750–L6800 (adjust_motion),
  L6803–L6955 (get_leave_ground_velocity),
  L6955–L7004 (DoInterpretedMotion),
  L7045–L7088 (StopMotion),
  L7132–L7189 (apply_current_movement),
  L7194–L7213 (jump),
  L7217–L7235 (apply_raw_movement),
  L7341–L7391 (move_to_interpreted_state / FUN_005295D0),
  L7395–L7441 (HitGround / LeaveGround),
  L7554–L7622 (DoMotion),
  L7627–L7658 (PerformMovement dispatcher).

- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`:
  L51–L110 DoInterpretedMotion, L112–L158 DoMotion, L236–L262 PerformMovement,
  L301–L327 StopCompletely, L394–L428 adjust_motion, L430–L504 apply_interpreted_movement,
  L506–L523 apply_raw_movement, L525–L562 apply_run_to_command, L678–L700 get_state_velocity,
  L710–L727 jump, L729–L779 jump_is_allowed / motion_allows_jump.

- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs`:
  L55–L257 GetObjectSequence (style/substate/action/modifier paths),
  L266–L291 SetDefaultState, L293–L356 StopObject*, L358–L393 add_motion/combine/subtract,
  L395–L426 get_link, L428–L438 is_allowed, L440–L458 re_modify.

- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs`:
  L83–L87 CombinePhysics, L145–L201 advance_to_next_animation,
  L203–L216 append_animation, L218–L230 apply_physics, L232–L243 apricot,
  L262–L270 execute_hooks, L277–L287 multiply_cyclic_animation_framerate,
  L351–L443 update_internal (time stepper).

- `references/DatReaderWriter/DatReaderWriter/Generated/`:
  `DBObjs/MotionTable.generated.cs` (L27–L57 schema fields),
  `DBObjs/Animation.generated.cs` (L27–L68),
  `Types/MotionData.generated.cs`, `Types/AnimData.generated.cs`,
  `Types/AnimationFrame.generated.cs`, `Types/AnimationHook.generated.cs` (L45–L128 polymorphic unpack),
  `Enums/AnimationHookType.generated.cs`, `Enums/MotionDataFlags.generated.cs`,
  `Enums/MotionCommand.generated.cs` (the full 450+ entry command catalog).

- `references/holtburger/crates/holtburger-protocol/src/messages/movement/`:
  `messages/motion.rs` (MovementEventData wire layout),
  `types.rs` (InterpretedMotionState, RawMotionState, MotionItem codec).

- `references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs`,
  `InterpretedMotionState.cs` (server-side writer).

- `references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs` (Style/SubState/etc. bits),
  `references/ACE/Source/ACE.Entity/Enum/WeenieError.cs` L148–L193 (error codes).
