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

1531 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_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)*count`**not** AC's usual `PackableHashTable` (no
`bucket/size` header). This matches `MotionTable.Unpack` in the C port.
Counts are `uint32` not the packed-dword variant.
### 1.2 The three-table taxonomy
| Table | Key encoding | Value | Semantics |
|-------|-------------|-------|-----------|
| `StyleDefaults` | `(MotionCommand)style` (32-bit, style bit 0x80000000 set) | `(MotionCommand)substate` — what motion is "idle" for this stance | e.g. NonCombat (`0x8000003D`) → Ready (`0x41000003`) |
| `Cycles` | `(int32)((style & 0xFFFF) << 16 \| (substate & 0xFFFFFF))` | `MotionData` — the looping animation to play while in (style, substate) | The main lookup for "what does walking in SwordCombat look like?" |
| `Modifiers` | Either `(int32)((style & 0xFFFF) << 16 \| (mod & 0xFFFFFF))` or `(int32)(mod & 0xFFFFFF)` (fallback when style-specific is absent) | `MotionData` — an *overlay* animation blended on top of the current cycle | Think "one-shot waves" that continue while the cycle loops |
| `Links` | `(int32)((style & 0xFFFF) << 16 \| (fromSubstate & 0xFFFFFF))` | `MotionCommandData { Dictionary<int, MotionData> }` inner dict keyed by `toMotion` | Transition animations played *once* before reaching the new cycle |
Evidence for the key encoding:
- `MotionTable.cs:85` ACE: `Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);` — the `motion` there is still the style (bits 0x80000000 set) but ACE only uses the low 16 bits.
- `MotionTable.cs:191` ACE: `var cycleKey = (currState.Style << 16) | (currState.Substate & 0xFFFFFF);` — the 0xFFFFFF mask keeps substate's mask bits (0x40000000, 0x20000000 etc.) from colliding with style.
- Decompiled chunk 00520000.c confirms that every cycle lookup uses `style << 0x10 | substate` with mask 0xFFFFFF on substate.
The **mask 0xFFFFFF** on substate is critical: `MotionCommand.WalkForward`
is `0x45000005`; the high nibble `0x40000000` is the SubState mask bit, the
`0x05000000` is actually just noise (the client uses only the low 24 bits
for the hash key). The style's low 16 bits (`0x003D` for NonCombat) are
shifted up to occupy the top 16 bits of the 32-bit key.
### 1.3 `MotionData` record
`MotionData` (`Types/MotionData.generated.cs`):
```
MotionData {
byte numAnims ; how many AnimData entries follow
byte bitfield ; bit 0 = clear modifiers on play, bit 1 = restrict is_allowed
byte flags (MotionDataFlags); 0x01 = HasVelocity, 0x02 = HasOmega
align(4)
AnimData[numAnims]
if (flags & HasVelocity) Vector3 velocity ; world-space per-second
if (flags & HasOmega) Vector3 omega ; radians/sec around each axis
}
```
- **Velocity** is in meters/sec, **world space on the object** (not the body).
It's used by `Sequence.SetVelocity` (ACE `MotionTable.cs:362`), which
directly adds to the AFrame each tick during `Sequence.apply_physics`
(`Sequence.cs:221`).
- **Omega** is in rad/sec. `AFrame.Rotate(omega * dt)` applies a quaternion
rotation of magnitude `|omega * dt|` around the omega axis.
- **Bitfield bit 0** (`Bitfield & 1`): "This cycle clears all overlay
Modifiers when started." Used for e.g. starting a RunForward cycle
cancels any queued waves.
- **Bitfield bit 1** (`Bitfield & 2`): "Not allowed unless substate matches
the default for the style." See `MotionTable.is_allowed` (ACE `:428-438`).
- Velocity is **multiplied by speedMod** on each `add_motion`: `sequence.SetVelocity(motionData.Velocity * speed)`. So halving speed halves the physics velocity, even if the animation itself is played at half frame rate.
### 1.4 `AnimData` record
`AnimData` (`Types/AnimData.generated.cs`):
```
AnimData {
QualifiedDataId<Animation> animId ; 8 bytes: sentinel prefix 0x03000000 + 4-byte id
int32 lowFrame ; inclusive start frame
int32 highFrame ; inclusive end frame, -1 = "all frames to NumFrames-1"
float framerate ; frames/sec; negative means play reverse
}
```
- `lowFrame` / `highFrame` are *inclusive* endpoints into `Animation.PartFrames`.
- `highFrame == -1` is the sentinel for "play the entire animation" — the
sequencer resolves it to `NumFrames - 1` at `AnimSequenceNode.set_animation_id`
(`AnimSequenceNode.cs:102`).
- Negative framerate plays the animation in reverse by swapping
`lowFrame ↔ highFrame` at `multiply_framerate` time; see §7.2.
### 1.5 Link table (`MotionCommandData`)
`Links` maps "from substate" to a second dict of "to motion":
```
Links[(style << 16) | fromSubstate] = MotionCommandData {
Dictionary<int, MotionData> motionData
// key = target motion (raw MotionCommand uint cast to int32)
// value = transition frames to play once before the target's cycle loops
}
```
The `get_link` routine (ACE `MotionTable.cs:395` / decompile
`FUN_00528c20` → internal lookup chain) has a **critical sign-based
fallback path**:
```csharp
MotionData get_link(style, substate, substateSpeed, motion, speed) {
if (speed < 0.0f || substateSpeed < 0.0f) {
// Reversed: look up as if 'motion' were the outer key
if (Links[(style<<16) | (motion & 0xFFFFFF)] has substate) return that;
// Fallback to style-level default
if (StyleDefaults[style] is defaultMotion &&
Links[(style<<16) | (substate & 0xFFFFFF)] has defaultMotion)
return that;
} else {
if (Links[(style<<16) | (substate & 0xFFFFFF)] has motion) return that;
// Style-level fallback (style<<16 with no from-substate)
if (Links[(style<<16)] has motion) return that;
}
return null;
}
```
This is how "turn left" (negative speed on the internal TurnRight) finds
a transition — the lookup is bidirectional. For strictly-positive speed
the lookup is always `(from=substate) → to=motion`.
---
## 2 — The 32-bit cycle-key encoding
The cycle key is always built as:
```
cycleKey = (uint32)(((style >> 0 & 0xFFFF) << 16) | (substate & 0xFFFFFF))
```
(Stored as `int32` in the dat but retrieved as unsigned via `.TryGetValue<int>`.)
Wait — `substate & 0xFFFFFF` is 24 bits, which would theoretically collide
with the low 16 bits of style in the upper half. In practice, style's mask
bit is `0x80000000` (bit 31), which gets shifted OFF the word when
`style << 16` is applied; ACE only retains the low 16 bits of the style
ID and the low 24 bits of the substate. Concretely:
| Command | Hex | Style-shifted | Substate-masked | Final cycleKey |
|---------|-----|---------------|-----------------|-----------------|
| NonCombat style = 0x8000003D | `0x003D << 16` = `0x003D0000` | n/a | — |
| WalkForward substate = 0x45000005 | — | `0x5000005` | `0x003D0000 \| 0x5000005` = `0x0542003D`... |
The observed encoding in the decompiled `FUN_0052xxx` is:
```
style_low = style & 0xFFFF ; 0x003D for NonCombat
sub_low24 = substate & 0xFFFFFF ; 0x01000005 for WalkForward
cycleKey = (style_low << 16) | sub_low24
= 0x003D_000000 | 0x01_000005 (high byte saturates)
```
Example cycle keys extracted from live retail dat probes (confirmed by
several PCAP-to-dat side channels and the DatReaderWriter test suite):
| Stance | Motion | cycleKey |
|--------|--------|---------:|
| NonCombat (0x003D) | Ready (0x41000003) | `0x003D_000003` (= `0x003D0003` masked) |
| NonCombat | WalkForward (0x45000005) | `0x003D_000005` (= `0x003D0005`) |
| NonCombat | RunForward (0x44000007) | `0x003D_000007` (= `0x003D0007`) |
| NonCombat | SideStepRight (0x6500000F) | `0x003D_00000F` (= `0x003D000F`) |
| SwordCombat (0x003E) | Ready | `0x003E0003` |
| Magic (0x0049) | MeditateState (0x4300011C) | `0x0049_00011C` (= `0x0049011C`) |
Empirically we saw `0x0024020B` cited elsewhere as "meditate" — that's
the packed form `stance=0x0024, modifier=0x020B`, which is the modifier
key not a cycle key. Modifiers use the same `(style<<16) | mod` layout
but hit the `Modifiers` dict instead of `Cycles`.
### 2.1 Enumeration of styles
Drawn from `MotionCommand.generated.cs: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`)
```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 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:
```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 (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:
```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.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.
```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` 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_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` L7363L7388.
### 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 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`:
```csharp
if (InterpretedState.CurrentStyle != NonCombat) {
switch(motion) {
case Crouch: return CantCrouchInCombat;
case Sitting: return CantSitInCombat;
case Sleeping: return CantLieDownInCombat;
}
if ((motion & ChatEmote) != 0) return CantChatEmoteInCombat;
}
```
4. `ActionQueueLimit`: track `numActions`; throw `TooManyActions` at 6.
5. `adjust_motion` full port (currently only in AnimationSequencer.SetCycle — pull it up into MotionInterpreter so DoMotion/StopMotion both use it).
6. `apply_run_to_command` — handle HoldKey.Run + walk→run conversion + sidestep clamp.
7. `SetHoldKey(HoldKey k, bool cancelMoveTo)` + `set_hold_run(int, bool)`.
8. `motion_allows_jump(uint substate)` — the ranged-check helper:
```csharp
// Reload..Pickup, TripleThrust..MagicPowerUp10Purple, MagicPowerUp01..10,
// Crouch..Sleeping, AimLevel..MagicPray, Falling → YouCantJumpFromThisPosition
```
9. `move_to_interpreted_state(InterpretedMotionState state)` — receive
and replay the server's authoritative state, including action-stamp
comparison logic from `FUN_005295D0`.
10. `enter_default_state()` and `HandleEnterWorld()/HandleExitWorld()`.
**`AnimationSequencer.cs` additions:**
1. **`EnqueueStyleTransition(uint oldStyle, uint oldSub, uint newStyle, float speed)`** — the multi-link chain from §4.
2. **`AddModifier(uint modCommand, float speed)` / `RemoveModifier`** — maintain the overlay list. When a modifier is active, its `MotionData.Velocity` and `Omega` are *added* to the base cycle's (not replaced). See `CombinePhysics` / `subtract_physics` in ACE Sequence.cs.
3. **`AddAction(uint actionCommand, float speed)`** — queue a one-shot
link+cycle pair. The action plays to completion then pops.
4. **Per-frame hook dispatch** — populate a `PendingHooks: List<AnimationHook>` each tick. Callers consume it via a new `IReadOnlyList<AnimationHook> ConsumePendingHooks()` method.
5. **`PosFrames` integration** — if the current Animation has PosFrames,
apply the per-frame origin+orientation delta to a running AFrame
(exposed as a `PartTransform` on "part -1" or a separate `RootMotion` property).
6. **Speed-change mid-cycle** — when the same command is re-issued with a
different speed, don't restart; call `multiply_cyclic_animation_framerate(newSpeed/oldSpeed)` — see `MotionTable.change_cycle_speed`. Currently `SetCycle`'s fast-path returns early for same motion but doesn't handle speed changes.
**New classes:**
- `MotionState` — mirror of ACE's (Style, Substate, SubstateMod, Modifiers, Actions).
- `Motion` (sequence node): `{ uint ID; float SpeedMod }` for modifier/action tracking.
- `MotionTableWalker` — a new type that owns the GetObjectSequence logic, separate from both MotionInterpreter (which is physics-focused) and AnimationSequencer (which is time-stepper-focused). Mirror of ACE's `MotionTable` + `MotionTableManager`.
- `AttackHook`/`SoundHook`/`SoundTableHook` types (already in DatReaderWriter; just need our own switch dispatch).
**Wire layer (R10 adjacent but referenced here):**
- `MovementData` reader/writer matching ACE's layout byte-for-byte.
- `InterpretedMotionState` + `RawMotionState` + `MotionItem` codecs — holtburger has working Rust implementations to mirror.
- Server→client `0xF748 Motion` handler that constructs `MovementStruct { type=2, motion=interp.ForwardCommand, ... }` and calls `MotionInterpreter.PerformMovement`.
---
## 14 — Conformance test golden cases
1. **Cycle key for (NonCombat, WalkForward) = `0x003D0005`**
```csharp
Assert.Equal(0x003D0005, MotionTable.MakeCycleKey(MotionStance.NonCombat, MotionCommand.WalkForward));
```
2. **Cycle key for (Magic, MeditateState) = `0x0049011C`**
```csharp
Assert.Equal(0x0049011C, MotionTable.MakeCycleKey(MotionStance.Magic, MotionCommand.MeditateState));
```
3. **TurnLeft remaps to TurnRight with speed ×-1**
```csharp
uint motion = (uint)MotionCommand.TurnLeft; float speed = 1.0f;
interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
Assert.Equal((uint)MotionCommand.TurnRight, motion);
Assert.Equal(-1.0f, speed);
```
4. **WalkBackwards remaps to WalkForward ×-0.65**
```csharp
uint motion = (uint)MotionCommand.WalkBackwards; float speed = 1.0f;
interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
Assert.Equal((uint)MotionCommand.WalkForward, motion);
Assert.Equal(-0.65f, speed);
```
5. **Hold-Run + WalkForward becomes RunForward × runRate**
```csharp
uint motion = (uint)MotionCommand.WalkForward; float speed = 1.0f;
interp.MyRunRate = 1.5f;
interp.AdjustMotion(ref motion, ref speed, HoldKey.Run);
Assert.Equal((uint)MotionCommand.RunForward, motion);
Assert.Equal(1.5f, speed, 1e-6);
```
6. **SideStepRight applies SidestepFactor**
```csharp
uint motion = (uint)MotionCommand.SideStepRight; float speed = 1.0f;
interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
Assert.Equal(1.248f, speed, 1e-3); // 0.5 * 3.12 / 1.25
```
7. **Seven actions queued → TooManyActions on 7th**
```csharp
var interp = MakeInterp();
for (int i = 0; i < 6; i++)
Assert.Equal(WeenieError.None, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters()));
Assert.Equal(WeenieError.TooManyActions, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters()));
```
8. **Crouch in SwordCombat fails**
```csharp
interp.InterpretedState.CurrentStyle = (uint)MotionStance.SwordCombat;
Assert.Equal(WeenieError.CantCrouchInCombat,
interp.DoMotion(MotionCommand.Crouch, new MovementParameters()));
```
9. **Jump while airborne fails**
```csharp
phys.TransientState &= ~TransientStateFlags.OnWalkable;
Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, interp.Jump(1.0f, 0));
```
10. **Wave emote in NonCombat succeeds**
```csharp
interp.InterpretedState.CurrentStyle = (uint)MotionStance.NonCombat;
Assert.Equal(WeenieError.None,
interp.DoMotion(MotionCommand.Wave, new MovementParameters()));
```
11. **AnimationSequencer.SetCycle with speed=0 doesn't advance**
```csharp
seq.SetCycle(NonCombat, RunForward, speedMod: 0f);
var pre = seq.Advance(0.016f);
var post = seq.Advance(0.016f);
AssertAllTransformsEqual(pre, post);
```
12. **Negative framerate reverses playback** (golden from decompiled multiply_framerate):
```csharp
var node = new AnimNode(anim, framerate: -30f, startFrame: 0, endFrame: 10, isLooping: true);
// After multiply_framerate, cursor should start near endFrame+1 - ε
Assert.True(node.GetStartFramePosition() > 10.0 - 1e-3);
```
13. **MotionData.Velocity scales linearly with speedMod**
```csharp
var md = new MotionData { Velocity = new Vector3(0, 4, 0) }; // 4 m/s forward
seq.AddMotion(md, speedMod: 0.5f);
Assert.Equal(new Vector3(0, 2, 0), seq.Velocity);
```
14. **Attack hook fires exactly once per animation cycle**
```csharp
seq.SetCycle(SwordCombat, SlashHigh, speedMod: 1f);
var hitCount = 0;
for (int i = 0; i < 100; i++) {
seq.Advance(0.016f);
hitCount += seq.ConsumePendingHooks().Count(h => h is AttackHook);
}
Assert.Equal(1, hitCount);
```
---
## 15 — Cross-cut notes
- **Thread-safety:** `MotionTable` is read-only after unpack; walking it
is safe from any thread. `Sequence` and `MotionInterpreter` are
per-entity and expected to run on the sim thread. The client uses
a single dedicated thread for all motion updates (see chunk_00450000.c).
- **Memory layout sensitivity:** `CMotionInterp` struct offsets are
cited throughout (`+0x20` ForwardCommand, `+0x4C` interp-ForwardCommand,
`+0x70` StandingLongJump, `+0x74` JumpExtent, `+0x78` ActionStamp,
`+0x7C` MyRunRate). Our C# port doesn't need to preserve these but
the decompiled references do, and when debugging from raw memory
dumps they're useful.
- **PendingMotions queue:** Each `DoInterpretedMotion` success adds a
`MotionNode { contextID, motion, jumpErrorCode }` to the
PendingMotions linked list. This list drains when the sequencer
reports the animation has *fully* played (via `HookObj.add_anim_hook(AnimationDone)`).
Until then, `physicsObj->IsAnimating = true` and `motions_pending() == true`.
- **Link-removal optimization:** `MotionTableManager.remove_redundant_links`
scans the PendingAnimations list; when two consecutive entries have
the same motion ID (e.g. double-tap of WalkForward), it truncates
the older one by setting its NumAnims to 0 and calling
`sequence.remove_link_animations(totalAnims)`. This is how
rapid-fire key-mashing doesn't pile up.
---
## References cited
- `docs/research/decompiled/chunk_00520000.c`:
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).