# 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 }` 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 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 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`.) 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` 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 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` each tick. Callers consume it via a new `IReadOnlyList 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).