acdream/docs/research/deepdives/r03-motion-animation.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

65 KiB
Raw Blame History

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.cCMotionInterp (FUN_00520000FUN_0052b9ff)
  • docs/research/decompiled/chunk_00520000.c L4368L4694 — 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)*countnot 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.

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:

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:792826:

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)

[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):

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 34 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:1371):

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:

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:

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 (515 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 150400 ms.

6.3 Frame epsilon

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

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):

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:

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:

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.52.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.

// 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 L148L193:

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 L6967L7005):

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_motionWalkForward + 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 L7363L7388.

10.3 The RawMotionState → InterpretedMotionState conversion

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

// 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 23 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:
    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:
    // 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

    Assert.Equal(0x003D0005, MotionTable.MakeCycleKey(MotionStance.NonCombat, MotionCommand.WalkForward));
    
  2. Cycle key for (Magic, MeditateState) = 0x0049011C

    Assert.Equal(0x0049011C, MotionTable.MakeCycleKey(MotionStance.Magic, MotionCommand.MeditateState));
    
  3. TurnLeft remaps to TurnRight with speed ×-1

    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

    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

    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

    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

    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

    interp.InterpretedState.CurrentStyle = (uint)MotionStance.SwordCombat;
    Assert.Equal(WeenieError.CantCrouchInCombat,
                 interp.DoMotion(MotionCommand.Crouch, new MovementParameters()));
    
  9. Jump while airborne fails

    phys.TransientState &= ~TransientStateFlags.OnWalkable;
    Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, interp.Jump(1.0f, 0));
    
  10. Wave emote in NonCombat succeeds

    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

    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):

    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

    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

    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: L3080L3117 (PerformMovement switch), L4368L4677 (update_internal + advance_to_next_animation), L4698L4784 (multiply_framerate, GetStart/EndFramePosition), L6433L6504 (add_to_queue, apply_run_to_command), L6565L6611 (get_state_velocity), L6616L6641 (StopCompletely), L6750L6800 (adjust_motion), L6803L6955 (get_leave_ground_velocity), L6955L7004 (DoInterpretedMotion), L7045L7088 (StopMotion), L7132L7189 (apply_current_movement), L7194L7213 (jump), L7217L7235 (apply_raw_movement), L7341L7391 (move_to_interpreted_state / FUN_005295D0), L7395L7441 (HitGround / LeaveGround), L7554L7622 (DoMotion), L7627L7658 (PerformMovement dispatcher).

  • references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs: L51L110 DoInterpretedMotion, L112L158 DoMotion, L236L262 PerformMovement, L301L327 StopCompletely, L394L428 adjust_motion, L430L504 apply_interpreted_movement, L506L523 apply_raw_movement, L525L562 apply_run_to_command, L678L700 get_state_velocity, L710L727 jump, L729L779 jump_is_allowed / motion_allows_jump.

  • references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs: L55L257 GetObjectSequence (style/substate/action/modifier paths), L266L291 SetDefaultState, L293L356 StopObject*, L358L393 add_motion/combine/subtract, L395L426 get_link, L428L438 is_allowed, L440L458 re_modify.

  • references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs: L83L87 CombinePhysics, L145L201 advance_to_next_animation, L203L216 append_animation, L218L230 apply_physics, L232L243 apricot, L262L270 execute_hooks, L277L287 multiply_cyclic_animation_framerate, L351L443 update_internal (time stepper).

  • references/DatReaderWriter/DatReaderWriter/Generated/: DBObjs/MotionTable.generated.cs (L27L57 schema fields), DBObjs/Animation.generated.cs (L27L68), Types/MotionData.generated.cs, Types/AnimData.generated.cs, Types/AnimationFrame.generated.cs, Types/AnimationHook.generated.cs (L45L128 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 L148L193 (error codes).