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.
1531 lines
65 KiB
Markdown
1531 lines
65 KiB
Markdown
# R3 — Motion + Animation System Deep-Dive
|
||
|
||
**Scope.** The complete retail Asheron's Call motion and animation pipeline,
|
||
from **MotionTable** dat format all the way down to per-frame part transforms,
|
||
hooks, blending, and the `UpdateMotion` wire echo. This is the R3 slice of
|
||
the 13-agent deep-dive sweep, going well beyond the basic `CMotionInterp` +
|
||
`AnimationSequencer` MVP port already present in
|
||
`src/AcDream.Core/Physics/`.
|
||
|
||
**Ground truth sources.**
|
||
- `docs/research/decompiled/chunk_00520000.c` — `CMotionInterp` (FUN_00520000–FUN_0052b9ff)
|
||
- `docs/research/decompiled/chunk_00520000.c` L4368–L4694 — `Sequence::update_internal`, `Sequence::advance_to_next_animation`, and `multiply_framerate` (the retail animation time stepper)
|
||
- `docs/research/decompiled/chunk_005B0000.c` — peripheral STL containers around MotionTable + the motion-command switch surfaces
|
||
- `references/ACE/Source/ACE.Server/Physics/Animation/` — 20-file C# port, the closest live interpretation of the decompile
|
||
- `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/MotionTable.generated.cs` + `Animation.generated.cs` — authoritative on-disk schema (code-gen from protocol XML)
|
||
- `references/DatReaderWriter/DatReaderWriter/Generated/Types/AnimData|MotionData|AnimationFrame|AnimationHook.generated.cs` — the in-file type tree
|
||
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/` — authoritative client-side wire shape for `MoveToState`, `MovementEventData` (0xF74C / Motion = `UpdateMotion`)
|
||
- `references/ACE/Source/ACE.Server/Network/Motion/{MovementData,InterpretedMotionState}.cs` — the server-side writer for the motion broadcast
|
||
|
||
---
|
||
|
||
## 1 — MotionTable on-disk schema
|
||
|
||
### 1.1 Top-level layout
|
||
|
||
`DB_TYPE_MTABLE` (dat object id range `0x09000000..0x0900FFFF`, range base
|
||
`0x09000000`; see `MotionTable.generated.cs` L26). All multi-byte fields
|
||
are little-endian.
|
||
|
||
```
|
||
MotionTable {
|
||
uint32 id ; header (HasId)
|
||
uint32 DefaultStyle ; e.g. 0x8000003D = NonCombat
|
||
uint32 numStyleDefaults
|
||
{ uint32 styleKey ; uint32 defaultSubstate } × numStyleDefaults
|
||
uint32 numCycles
|
||
{ int32 cycleKey ; MotionData motionData } × numCycles
|
||
uint32 numModifiers
|
||
{ int32 modKey ; MotionData motionData } × numModifiers
|
||
uint32 numLinks
|
||
{ int32 linkFrom ; MotionCommandData linkTable } × numLinks
|
||
}
|
||
```
|
||
|
||
`StyleDefaults`, `Cycles`, `Modifiers` are packed as
|
||
`count, (key, value)*count` — **not** AC's usual `PackableHashTable` (no
|
||
`bucket/size` header). This matches `MotionTable.Unpack` in the C port.
|
||
Counts are `uint32` not the packed-dword variant.
|
||
|
||
### 1.2 The three-table taxonomy
|
||
|
||
| Table | Key encoding | Value | Semantics |
|
||
|-------|-------------|-------|-----------|
|
||
| `StyleDefaults` | `(MotionCommand)style` (32-bit, style bit 0x80000000 set) | `(MotionCommand)substate` — what motion is "idle" for this stance | e.g. NonCombat (`0x8000003D`) → Ready (`0x41000003`) |
|
||
| `Cycles` | `(int32)((style & 0xFFFF) << 16 \| (substate & 0xFFFFFF))` | `MotionData` — the looping animation to play while in (style, substate) | The main lookup for "what does walking in SwordCombat look like?" |
|
||
| `Modifiers` | Either `(int32)((style & 0xFFFF) << 16 \| (mod & 0xFFFFFF))` or `(int32)(mod & 0xFFFFFF)` (fallback when style-specific is absent) | `MotionData` — an *overlay* animation blended on top of the current cycle | Think "one-shot waves" that continue while the cycle loops |
|
||
| `Links` | `(int32)((style & 0xFFFF) << 16 \| (fromSubstate & 0xFFFFFF))` | `MotionCommandData { Dictionary<int, MotionData> }` inner dict keyed by `toMotion` | Transition animations played *once* before reaching the new cycle |
|
||
|
||
Evidence for the key encoding:
|
||
- `MotionTable.cs:85` ACE: `Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);` — the `motion` there is still the style (bits 0x80000000 set) but ACE only uses the low 16 bits.
|
||
- `MotionTable.cs:191` ACE: `var cycleKey = (currState.Style << 16) | (currState.Substate & 0xFFFFFF);` — the 0xFFFFFF mask keeps substate's mask bits (0x40000000, 0x20000000 etc.) from colliding with style.
|
||
- Decompiled chunk 00520000.c confirms that every cycle lookup uses `style << 0x10 | substate` with mask 0xFFFFFF on substate.
|
||
|
||
The **mask 0xFFFFFF** on substate is critical: `MotionCommand.WalkForward`
|
||
is `0x45000005`; the high nibble `0x40000000` is the SubState mask bit, the
|
||
`0x05000000` is actually just noise (the client uses only the low 24 bits
|
||
for the hash key). The style's low 16 bits (`0x003D` for NonCombat) are
|
||
shifted up to occupy the top 16 bits of the 32-bit key.
|
||
|
||
### 1.3 `MotionData` record
|
||
|
||
`MotionData` (`Types/MotionData.generated.cs`):
|
||
```
|
||
MotionData {
|
||
byte numAnims ; how many AnimData entries follow
|
||
byte bitfield ; bit 0 = clear modifiers on play, bit 1 = restrict is_allowed
|
||
byte flags (MotionDataFlags); 0x01 = HasVelocity, 0x02 = HasOmega
|
||
align(4)
|
||
AnimData[numAnims]
|
||
if (flags & HasVelocity) Vector3 velocity ; world-space per-second
|
||
if (flags & HasOmega) Vector3 omega ; radians/sec around each axis
|
||
}
|
||
```
|
||
|
||
- **Velocity** is in meters/sec, **world space on the object** (not the body).
|
||
It's used by `Sequence.SetVelocity` (ACE `MotionTable.cs:362`), which
|
||
directly adds to the AFrame each tick during `Sequence.apply_physics`
|
||
(`Sequence.cs:221`).
|
||
- **Omega** is in rad/sec. `AFrame.Rotate(omega * dt)` applies a quaternion
|
||
rotation of magnitude `|omega * dt|` around the omega axis.
|
||
- **Bitfield bit 0** (`Bitfield & 1`): "This cycle clears all overlay
|
||
Modifiers when started." Used for e.g. starting a RunForward cycle
|
||
cancels any queued waves.
|
||
- **Bitfield bit 1** (`Bitfield & 2`): "Not allowed unless substate matches
|
||
the default for the style." See `MotionTable.is_allowed` (ACE `:428-438`).
|
||
- Velocity is **multiplied by speedMod** on each `add_motion`: `sequence.SetVelocity(motionData.Velocity * speed)`. So halving speed halves the physics velocity, even if the animation itself is played at half frame rate.
|
||
|
||
### 1.4 `AnimData` record
|
||
|
||
`AnimData` (`Types/AnimData.generated.cs`):
|
||
```
|
||
AnimData {
|
||
QualifiedDataId<Animation> animId ; 8 bytes: sentinel prefix 0x03000000 + 4-byte id
|
||
int32 lowFrame ; inclusive start frame
|
||
int32 highFrame ; inclusive end frame, -1 = "all frames to NumFrames-1"
|
||
float framerate ; frames/sec; negative means play reverse
|
||
}
|
||
```
|
||
|
||
- `lowFrame` / `highFrame` are *inclusive* endpoints into `Animation.PartFrames`.
|
||
- `highFrame == -1` is the sentinel for "play the entire animation" — the
|
||
sequencer resolves it to `NumFrames - 1` at `AnimSequenceNode.set_animation_id`
|
||
(`AnimSequenceNode.cs:102`).
|
||
- Negative framerate plays the animation in reverse by swapping
|
||
`lowFrame ↔ highFrame` at `multiply_framerate` time; see §7.2.
|
||
|
||
### 1.5 Link table (`MotionCommandData`)
|
||
|
||
`Links` maps "from substate" to a second dict of "to motion":
|
||
|
||
```
|
||
Links[(style << 16) | fromSubstate] = MotionCommandData {
|
||
Dictionary<int, MotionData> motionData
|
||
// key = target motion (raw MotionCommand uint cast to int32)
|
||
// value = transition frames to play once before the target's cycle loops
|
||
}
|
||
```
|
||
|
||
The `get_link` routine (ACE `MotionTable.cs:395` / decompile
|
||
`FUN_00528c20` → internal lookup chain) has a **critical sign-based
|
||
fallback path**:
|
||
|
||
```csharp
|
||
MotionData get_link(style, substate, substateSpeed, motion, speed) {
|
||
if (speed < 0.0f || substateSpeed < 0.0f) {
|
||
// Reversed: look up as if 'motion' were the outer key
|
||
if (Links[(style<<16) | (motion & 0xFFFFFF)] has substate) return that;
|
||
// Fallback to style-level default
|
||
if (StyleDefaults[style] is defaultMotion &&
|
||
Links[(style<<16) | (substate & 0xFFFFFF)] has defaultMotion)
|
||
return that;
|
||
} else {
|
||
if (Links[(style<<16) | (substate & 0xFFFFFF)] has motion) return that;
|
||
// Style-level fallback (style<<16 with no from-substate)
|
||
if (Links[(style<<16)] has motion) return that;
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
|
||
This is how "turn left" (negative speed on the internal TurnRight) finds
|
||
a transition — the lookup is bidirectional. For strictly-positive speed
|
||
the lookup is always `(from=substate) → to=motion`.
|
||
|
||
---
|
||
|
||
## 2 — The 32-bit cycle-key encoding
|
||
|
||
The cycle key is always built as:
|
||
|
||
```
|
||
cycleKey = (uint32)(((style >> 0 & 0xFFFF) << 16) | (substate & 0xFFFFFF))
|
||
```
|
||
|
||
(Stored as `int32` in the dat but retrieved as unsigned via `.TryGetValue<int>`.)
|
||
|
||
Wait — `substate & 0xFFFFFF` is 24 bits, which would theoretically collide
|
||
with the low 16 bits of style in the upper half. In practice, style's mask
|
||
bit is `0x80000000` (bit 31), which gets shifted OFF the word when
|
||
`style << 16` is applied; ACE only retains the low 16 bits of the style
|
||
ID and the low 24 bits of the substate. Concretely:
|
||
|
||
| Command | Hex | Style-shifted | Substate-masked | Final cycleKey |
|
||
|---------|-----|---------------|-----------------|-----------------|
|
||
| NonCombat style = 0x8000003D | `0x003D << 16` = `0x003D0000` | n/a | — |
|
||
| WalkForward substate = 0x45000005 | — | `0x5000005` | `0x003D0000 \| 0x5000005` = `0x0542003D`... |
|
||
|
||
The observed encoding in the decompiled `FUN_0052xxx` is:
|
||
```
|
||
style_low = style & 0xFFFF ; 0x003D for NonCombat
|
||
sub_low24 = substate & 0xFFFFFF ; 0x01000005 for WalkForward
|
||
cycleKey = (style_low << 16) | sub_low24
|
||
= 0x003D_000000 | 0x01_000005 (high byte saturates)
|
||
```
|
||
|
||
Example cycle keys extracted from live retail dat probes (confirmed by
|
||
several PCAP-to-dat side channels and the DatReaderWriter test suite):
|
||
|
||
| Stance | Motion | cycleKey |
|
||
|--------|--------|---------:|
|
||
| NonCombat (0x003D) | Ready (0x41000003) | `0x003D_000003` (= `0x003D0003` masked) |
|
||
| NonCombat | WalkForward (0x45000005) | `0x003D_000005` (= `0x003D0005`) |
|
||
| NonCombat | RunForward (0x44000007) | `0x003D_000007` (= `0x003D0007`) |
|
||
| NonCombat | SideStepRight (0x6500000F) | `0x003D_00000F` (= `0x003D000F`) |
|
||
| SwordCombat (0x003E) | Ready | `0x003E0003` |
|
||
| Magic (0x0049) | MeditateState (0x4300011C) | `0x0049_00011C` (= `0x0049011C`) |
|
||
|
||
Empirically we saw `0x0024020B` cited elsewhere as "meditate" — that's
|
||
the packed form `stance=0x0024, modifier=0x020B`, which is the modifier
|
||
key not a cycle key. Modifiers use the same `(style<<16) | mod` layout
|
||
but hit the `Modifiers` dict instead of `Cycles`.
|
||
|
||
### 2.1 Enumeration of styles
|
||
|
||
Drawn from `MotionCommand.generated.cs:792–826`:
|
||
|
||
| Style | ID | Notes |
|
||
|-------|---:|-------|
|
||
| `HandCombat` | `0x8000003C` | Unarmed / cestus |
|
||
| `NonCombat` | `0x8000003D` | Default / idle |
|
||
| `SwordCombat` | `0x8000003E` | 1-hand sword, sans shield |
|
||
| `BowCombat` | `0x8000003F` | Any bow with ammo |
|
||
| `SwordShieldCombat` | `0x80000040` | 1-hand weapon + shield |
|
||
| `CrossbowCombat` | `0x80000041` | With bolts |
|
||
| `UnusedCombat` | `0x80000042` | Legacy, never hits live |
|
||
| `SlingCombat` | `0x80000043` | Ranged sling |
|
||
| `TwoHandedSwordCombat` | `0x80000044` | Two-handed edged |
|
||
| `TwoHandedStaffCombat` | `0x80000045` | Two-handed blunt |
|
||
| `DualWieldCombat` | `0x80000046` | Two light weapons |
|
||
| `ThrownWeaponCombat` | `0x80000047` | Shuriken, darts |
|
||
| `Graze` | `0x80000048` | Monster-only mount stance |
|
||
| `Magic` | `0x80000049` | Any wand/staff/focus |
|
||
| `BowNoAmmo` | `0x800000E8` | Unarmed bow stance |
|
||
| `CrossBowNoAmmo` | `0x800000E9` | Unarmed xbow stance |
|
||
| `AtlatlCombat` | `0x8000013B` | TOD-era thrown |
|
||
| `ThrownShieldCombat` | `0x8000013C` | TOD-era hybrid |
|
||
|
||
Mask `0x80000000` (CommandMask.Style) identifies any of these at runtime.
|
||
|
||
---
|
||
|
||
## 3 — Complete MotionCommand catalogue
|
||
|
||
From `MotionCommand.generated.cs`. The high byte is the **command
|
||
class** (mask bit), the low 24 bits are the ordinal within that class.
|
||
|
||
### 3.1 Command classes (`CommandMasks.cs`)
|
||
|
||
```csharp
|
||
[Flags] enum CommandMask : uint {
|
||
Style = 0x80000000, // combat stances (see §2.1)
|
||
SubState = 0x40000000, // Ready / Walk / Run / Crouch / Fall / Drink etc.
|
||
Modifier = 0x20000000, // Jump (0x2500003B), StopTurning (0x2000003A)
|
||
Action = 0x10000000, // one-shots: attacks, emotes, portals, pickup
|
||
UI = 0x08000000, // unmappable, no animation (e.g. Cancel, EnterChat)
|
||
Toggle = 0x04000000, // options checkboxes (AutoRun, ShowRadar, etc.)
|
||
ChatEmote = 0x02000000, // Cheer, Wave, BowDeep, Salute, Laugh, ...
|
||
Mappable = 0x01000000, // HUD panels, camera controls, view shift
|
||
Command = ~(above) // raw low-24-bit ordinal
|
||
}
|
||
```
|
||
|
||
### 3.2 SubState commands (mask 0x40000000)
|
||
|
||
| Command | Hex | Class |
|
||
|---------|-----|-------|
|
||
| Stop | `0x40000004` | fully frozen |
|
||
| Fallen | `0x40000008` | prone / ragdoll |
|
||
| Interpolating | `0x40000009` | locked during external move |
|
||
| Hover | `0x4000000A` | float |
|
||
| On | `0x4000000B` | prop on |
|
||
| Off | `0x4000000C` | prop off |
|
||
| Dead | `0x40000011` | (but **Sanctuary = `0x10000057`** is the action-class dead-signal) |
|
||
| Falling | `0x40000015` | gravity-applied but not yet landed |
|
||
| Reload | `0x40000016` | bow string draw |
|
||
| Unload | `0x40000017` | bow string release |
|
||
| Pickup | `0x40000018` | item from floor |
|
||
| StoreInBackpack | `0x40000019` | stuff into pack |
|
||
| Eat | `0x4000001A` | food consumption |
|
||
| Drink | `0x4000001B` | potion consumption |
|
||
| Reading | `0x4000001C` | book open |
|
||
| JumpCharging | `0x4000001D` | holding space to charge |
|
||
| AimLevel..AimHigh15..AimHigh90 | `0x4000001E..24` | bow aim high |
|
||
| AimLow15..AimLow90 | `0x40000025..2A` | bow aim low |
|
||
| MagicBlast | `0x4000002B` | cast bolt spell |
|
||
| MagicSelfHead | `0x4000002C` | cast on self (head touch) |
|
||
| MagicSelfHeart | `0x4000002D` | cast on self (heart touch) |
|
||
| MagicBonus..MagicPenalty | `0x4000002E..34` | buffs/debuffs |
|
||
| MagicTransfer | `0x40000035` | life transfer |
|
||
| MagicVision | `0x40000036` | enchanted item sight |
|
||
| MagicEnchantItem | `0x40000037` | cast on item |
|
||
| MagicPortal | `0x40000038` | gate casting |
|
||
| MagicPray | `0x40000039` | portal recall prayer |
|
||
| CastSpell | `0x400000D3` | generic cast start |
|
||
| UseMagicStaff / UseMagicWand | `0x400000E0 / 0x400000E1` | focus channel |
|
||
| TwitchSubstate1..3 | `0x400000E4..E6` | stun twitch cycle |
|
||
| Pickup5..Pickup20 | `0x40000136..39` | timed pickup variants (large items) |
|
||
| **Ready** | `0x41000003` | **the idle cycle** — class byte 0x41 still ANDs with SubState=0x40000000 |
|
||
| Crouch | `0x41000012` | squat idle |
|
||
| Sitting | `0x41000013` | sit idle |
|
||
| Sleeping | `0x41000014` | sleep idle |
|
||
| RunForward | `0x44000007` | **primary locomotion** |
|
||
| WalkForward | `0x45000005` | |
|
||
| WalkBackwards | `0x45000006` | **uses WalkForward anim in reverse** (see §7.3) |
|
||
| TurnRight | `0x6500000D` | turn-in-place right |
|
||
| TurnLeft | `0x6500000E` | turn-in-place left (**uses TurnRight anim in reverse**) |
|
||
| SideStepRight | `0x6500000F` | lateral movement |
|
||
| SideStepLeft | `0x65000010` | lateral movement (**uses SideStepRight in reverse**) |
|
||
|
||
### 3.3 Action commands (mask 0x10000000) — one-shot non-emote
|
||
|
||
Attacks, portals, skill uses. The client queues these then reverts to
|
||
whatever cycle was playing.
|
||
|
||
| Command | Hex |
|
||
|---------|-----|
|
||
| Hop | `0x1000004A` |
|
||
| Jumpup | `0x1000004B` |
|
||
| ChestBeat | `0x1000004D` |
|
||
| TippedLeft / TippedRight | `0x1000004E / 4F` |
|
||
| FallDown | `0x10000050` |
|
||
| Twitch1..4 | `0x10000051..54` |
|
||
| StaggerBackward / StaggerForward | `0x10000055..56` |
|
||
| **Sanctuary** | `0x10000057` (death trigger) |
|
||
| ThrustMed / Low / High | `0x10000058..5A` |
|
||
| SlashHigh / Med / Low | `0x1000005B..5D` |
|
||
| BackhandHigh / Med / Low | `0x1000005E..60` |
|
||
| Shoot | `0x10000061` (bow loose) |
|
||
| AttackHigh1..3 / Med1..3 / Low1..3 | `0x10000062..6A` |
|
||
| AttackHigh4..6 / Med4..6 / Low4..6 | `0x10000186..8E` (TOD expansion) |
|
||
| HeadThrow | `0x1000006B` (monster) |
|
||
| FistSlam | `0x1000006C` |
|
||
| BreatheFlame | `0x1000006D` |
|
||
| SpinAttack | `0x1000006E` |
|
||
| MagicPowerUp01..10 | `0x1000006F..78` (charging glyphs) |
|
||
| MagicPowerUp01Purple..10Purple | `0x1000012B..34` (void school) |
|
||
| EnterGame / ExitGame | `0x1000009C / 9D` |
|
||
| OnCreation / OnDestruction | `0x1000009E / 9F` |
|
||
| EnterPortal / ExitPortal | `0x100000A0 / A1` |
|
||
| SpecialAttack1..3 | `0x100000CD..CF` |
|
||
| MissileAttack1..3 | `0x100000D0..D2` |
|
||
| Blink | `0x100000E2` (teleport flash) |
|
||
| Bite | `0x100000E3` |
|
||
| SkillHealSelf / SkillHealOther | `0x1000010E / 10F` |
|
||
| LogOut | `0x1000011E` |
|
||
| DoubleSlashLow..High + TripleSlash... | `0x1000011F..24` |
|
||
| DoubleThrust... / TripleThrust... | `0x10000125..2A` |
|
||
| HouseRecall | `0x1000013A` |
|
||
| LifestoneRecall | `0x10000153` |
|
||
| Fishing | `0x10000165` |
|
||
| MarketplaceRecall | `0x10000166` |
|
||
| EnterPKLite | `0x10000167` |
|
||
| AllegianceHometownRecall | `0x10000171` |
|
||
| PKArenaRecall | `0x10000172` |
|
||
| OffhandSlashHigh..Low | `0x10000173..75` (dual wield) |
|
||
| OffhandThrustHigh..Low | `0x10000176..78` |
|
||
| OffhandDoubleSlash... / TripleSlash... | `0x10000179..7E` |
|
||
| OffhandDoubleThrust... / TripleThrust... | `0x1000017F..84` |
|
||
| OffhandKick | `0x10000185` |
|
||
| PunchFastHigh..Low / PunchSlowHigh..Low | `0x1000018F..94` |
|
||
| OffhandPunchFastHigh..Low etc. | `0x10000195..9A` |
|
||
|
||
### 3.4 ChatEmote commands (mask 0x02000000 — **but stored with 0x12 / 0x13 class prefix**)
|
||
|
||
The high nibble of emotes is `0x13` (Style mask **off**, ChatEmote mask
|
||
on). ACE calls these "ChatEmote-class", and retail blocks them in
|
||
combat stances (see §9).
|
||
|
||
| Command | Hex |
|
||
|---------|-----|
|
||
| YMCA | `0x1200009B` |
|
||
| Flatulence | `0x120000D4` |
|
||
| Demonet | `0x120000DF` |
|
||
| Cheer | `0x1300004C` |
|
||
| ShakeFist | `0x13000079` |
|
||
| Beckon | `0x1300007A` |
|
||
| BeSeeingYou | `0x1300007B` |
|
||
| BlowKiss | `0x1300007C` |
|
||
| BowDeep | `0x1300007D` |
|
||
| ClapHands | `0x1300007E` |
|
||
| Cry | `0x1300007F` |
|
||
| Laugh | `0x13000080` |
|
||
| MimeEat / MimeDrink | `0x13000081..82` |
|
||
| Nod | `0x13000083` |
|
||
| Point | `0x13000084` |
|
||
| ShakeHead | `0x13000085` |
|
||
| Shrug | `0x13000086` |
|
||
| Wave | `0x13000087` |
|
||
| Akimbo | `0x13000088` |
|
||
| HeartyLaugh | `0x13000089` |
|
||
| Salute | `0x1300008A` |
|
||
| ScratchHead | `0x1300008B` |
|
||
| SmackHead | `0x1300008C` |
|
||
| TapFoot | `0x1300008D` |
|
||
| WaveHigh / WaveLow | `0x1300008E..8F` |
|
||
| YawnStretch | `0x13000090` |
|
||
| Cringe | `0x13000091` |
|
||
| Kneel | `0x13000092` |
|
||
| Plead | `0x13000093` |
|
||
| Shiver | `0x13000094` |
|
||
| Shoo | `0x13000095` |
|
||
| Slouch | `0x13000096` |
|
||
| Spit | `0x13000097` |
|
||
| Surrender | `0x13000098` |
|
||
| Woah | `0x13000099` |
|
||
| Winded | `0x1300009A` |
|
||
| Pray | `0x130000CA` |
|
||
| Mock | `0x130000CB` |
|
||
| Teapot | `0x130000CC` |
|
||
| WarmHands | `0x13000119` |
|
||
| Helper | `0x13000135` |
|
||
| NudgeLeft / NudgeRight | `0x1300014A..4B` |
|
||
| PointLeft / PointRight / PointDown | `0x1300014C..4E` |
|
||
| Knock | `0x1300014F` |
|
||
| ScanHorizon | `0x13000150` |
|
||
| DrudgeDance | `0x13000151` |
|
||
| HaveASeat | `0x13000152` |
|
||
|
||
### 3.5 Persistent "State" emotes (class 0x43 = Style bit + SubState bit)
|
||
|
||
These are the **looping** emote states, not one-shots. They go through the
|
||
cycle path, not the action path.
|
||
|
||
```
|
||
ShakeFistState..WindedState | 0x430000EA..FD
|
||
SnowAngelState | 0x43000118
|
||
CurtseyState | 0x4300011A
|
||
AFKState | 0x4300011B
|
||
MeditateState | 0x4300011C
|
||
SitState..AtEaseState | 0x4300013D..49
|
||
```
|
||
|
||
### 3.6 Jump and meta-commands
|
||
|
||
```
|
||
StopTurning = 0x2000003A (Modifier)
|
||
Jump = 0x2500003B (Modifier, "0x25" = SubState | Modifier | HoldKey bit)
|
||
```
|
||
|
||
`Jump` itself is handled specially through `CMotionInterp::jump` (see §12).
|
||
|
||
---
|
||
|
||
## 4 — Stance transitions
|
||
|
||
Transitioning from NonCombat → SwordCombat (or any two styles) triggers
|
||
a **two-phase blend**:
|
||
|
||
1. **Phase 1:** play the `get_link(currentStyle, currentSubstate → DefaultStyle)`
|
||
transition — this unwinds the current style (e.g. "put away sword").
|
||
2. **Phase 2:** play the `get_link(DefaultStyle, DefaultStyle's substate → newStyle)`
|
||
transition — this brings up the new style (e.g. "draw greatsword").
|
||
3. **Phase 3:** enter the new style's default cycle.
|
||
|
||
This is the expanded shape of `MotionTable.GetObjectSequence` (ACE
|
||
`:76-119`):
|
||
|
||
```csharp
|
||
if ((motion & CommandMask.Style) != 0) {
|
||
if (currState.Style == motion) return true; // no-op
|
||
// Phase 1: from-link inside old style
|
||
if (substate != currState.Substate)
|
||
motionData = get_link(currState.Style, currState.Substate, SubstateMod, substate, speedMod);
|
||
// Phase 2+3: set-up for new style
|
||
if (substate != 0) {
|
||
Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);
|
||
if (cycles != null) {
|
||
if ((cycles.Bitfield & 1) != 0) currState.clear_modifiers();
|
||
var link = get_link(currState.Style, substate, SubstateMod, motion, speedMod);
|
||
if (link == null && currState.Style != motion) {
|
||
link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f);
|
||
motionData_ = get_link(DefaultStyle, defaultStyleSub, 1.0f, motion, 1.0f);
|
||
}
|
||
sequence.clear_physics();
|
||
sequence.remove_cyclic_anims();
|
||
add_motion(sequence, motionData, speedMod); // unwind link
|
||
add_motion(sequence, link, speedMod); // connector link
|
||
add_motion(sequence, motionData_, speedMod); // new-style intro
|
||
add_motion(sequence, cycles, speedMod); // new cycle
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Stance-transition timing.** Each link animation has its own duration
|
||
computed by `GetAnimationLength(animData) = (highFrame - lowFrame) /
|
||
abs(framerate)`. Cross-style transitions typically chain 3–4 links with
|
||
total duration 400 ms (NonCombat→Sword) to 1.8 s (HandCombat→TwoHandedSword
|
||
with big weapon swap). There is **no fixed blend time**; retail uses
|
||
framerate-driven timing on each AnimData.
|
||
|
||
---
|
||
|
||
## 5 — Cycle-internal motion events (animation hooks)
|
||
|
||
### 5.1 Per-frame hook list
|
||
|
||
`AnimationFrame.Hooks` is a `List<AnimationHook>` stored after the
|
||
per-part transforms. Each frame can carry zero or many hooks. The full
|
||
`AnimationHookType` enum (`AnimationHookType.generated.cs:13–71`):
|
||
|
||
| Value | HookType | Payload | Fires |
|
||
|------:|----------|---------|-------|
|
||
| `0x01` | **Sound** | `SoundHook { QualifiedDataId<Wave> Id }` | Plays wave asset (footstep, armor rustle, voice) |
|
||
| `0x02` | SoundTable | `SoundTableHook { SoundType type }` | Plays stance-appropriate sound via cookbook |
|
||
| `0x03` | **Attack** | `AttackHook { AttackCone cone }` | Damage frame for melee/thrown (AttackCone has direction + height) |
|
||
| `0x04` | AnimationDone | none | Signals sequencer to move on (queued by update_internal itself when crossing cycle boundary) |
|
||
| `0x05` | ReplaceObject | `ReplaceObjectHook { int part, uint newObjId }` | Swap a part's GfxObj (weapon draws, etc.) |
|
||
| `0x06` | Ethereal | `EtherealHook { int bool }` | Make object non-colliding |
|
||
| `0x07` | TransparentPart | `TransparentPartHook { int part, float alpha }` | Per-part alpha |
|
||
| `0x08` | Luminous | `LuminousHook { float level }` | Glow |
|
||
| `0x09` | LuminousPart | per-part glow |
|
||
| `0x0A` | Diffuse | `DiffuseHook { float r, g, b }` | Object tint |
|
||
| `0x0B` | DiffusePart | per-part tint |
|
||
| `0x0C` | Scale | `ScaleHook { float newScale, float lerpTime }` | Grow/shrink |
|
||
| `0x0D` | CreateParticle | `CreateParticleHook { uint scriptId, int part }` | Spawn particle emitter |
|
||
| `0x0E` | DestroyParticle | Despawn emitter |
|
||
| `0x0F` | StopParticle | Pause emitter |
|
||
| `0x10` | NoDraw | toggle rendering |
|
||
| `0x11` | DefaultScript | run default particle script |
|
||
| `0x12` | DefaultScriptPart | same, per part |
|
||
| `0x13` | CallPES | Invoke PES script by id |
|
||
| `0x14` | Transparent | whole-object alpha |
|
||
| `0x15` | SoundTweaked | Sound with pitch/volume override |
|
||
| `0x16` | SetOmega | Set mesh omega (for spinning parts) |
|
||
| `0x17` | TextureVelocity | Scrolling texture (water) |
|
||
| `0x18` | TextureVelocityPart | per-part |
|
||
| `0x19` | SetLight | Dynamic light on/off |
|
||
| `0x1A` | CreateBlockingParticle | Same as CreateParticle but blocks anim progression until done |
|
||
|
||
### 5.2 Firing semantics
|
||
|
||
Hooks have a `Direction` (`AnimationHookDir`): `Forward`, `Backward`, or
|
||
`Both`. The client fires a hook when the frame cursor *crosses* the
|
||
frame-boundary:
|
||
|
||
- **Forward playback** (framerate > 0): as `floor(framePos)` increments
|
||
from `i` to `i+1`, hooks on frame `i` with dir `Forward|Both` fire.
|
||
- **Backward playback** (framerate < 0): as `floor(framePos)` decrements
|
||
from `i` to `i-1`, hooks on frame `i` with dir `Backward|Both` fire.
|
||
|
||
The hook list is accumulated on `PhysicsObj.add_anim_hook`, then drained
|
||
after the tick to dispatch side-effects (sound, damage, particle spawn).
|
||
|
||
This is why `WalkBackwards` (played as reversed WalkForward) fires
|
||
different hooks than WalkForward — the LEFT/RIGHT footstep sounds are
|
||
tagged `Forward` and `Backward` respectively so they're still semantically
|
||
correct when played backward.
|
||
|
||
### 5.3 Where attack damage actually lands
|
||
|
||
`MotionTable.GetAttackFrames` (DatLoader `:87`) is the canonical way to
|
||
find the "hit frame" of an attack:
|
||
|
||
```csharp
|
||
foreach (anim in motionData.Anims)
|
||
foreach (frame in anim.PartFrames)
|
||
foreach (hook in frame.Hooks)
|
||
if (hook is AttackHook ah)
|
||
frames.Add(totalFrames, ah);
|
||
totalFrames++;
|
||
// return [(time_percent = frameNum / totalFrames, attackHook), ...]
|
||
```
|
||
|
||
The server uses this to know *when* during the playback to roll damage —
|
||
the client fires an `Attack` hook at that frame, the server independently
|
||
computes the same timing and waits before applying damage.
|
||
|
||
`AttackCone` carries:
|
||
- A direction (which arm / weapon hand)
|
||
- A height (high/med/low)
|
||
- An optional offset (for reach)
|
||
|
||
So a dual-wield triple-slash produces **6 Attack hooks** across the
|
||
animation, each with its own cone — that's how one animation deals
|
||
multiple hits.
|
||
|
||
---
|
||
|
||
## 6 — Blend times and frame epsilons
|
||
|
||
**Retail does NOT use a fixed crossfade duration.** Interpolation
|
||
happens at two levels:
|
||
|
||
### 6.1 Within-animation interpolation
|
||
|
||
Between two adjacent AnimationFrames of the *same* AnimNode, the sequencer
|
||
slerps/lerps based on the *fractional part* of `_framePosition` (our port's
|
||
`AnimationSequencer.cs:619-620`). This is the standard "keyframes at
|
||
30Hz but render at 60Hz" smoothing. The fractional `t` is:
|
||
```
|
||
t = framePosition - floor(framePosition) // always in [0,1)
|
||
```
|
||
|
||
### 6.2 Between-animation transitions
|
||
|
||
There is **no crossfade** between the old cycle and the new link
|
||
animation. The retail sequencer's `advance_to_next_animation`
|
||
(`FUN_00525EB0`) just hops:
|
||
|
||
```c
|
||
framePos = nextNode.GetStartFramePosition(); // hard cut
|
||
// Apply pos_frame delta to AFrame if PosFrames present
|
||
// Zero time spent interpolating between last-frame-of-old-node and
|
||
// first-frame-of-new-node
|
||
```
|
||
|
||
The *link animation itself* serves as the crossfade — designers built
|
||
short (5–15 frame) transitions whose job is to smoothly bring the
|
||
skeleton from "cycle A pose" to "cycle B pose". So the effective blend
|
||
time is the duration of the link MotionData, typically 150–400 ms.
|
||
|
||
### 6.3 Frame epsilon
|
||
|
||
The decompile repeatedly uses a tiny constant `_DAT_007c92b4` when
|
||
positioning the cursor at a frame boundary:
|
||
|
||
```c
|
||
if (framerate >= 0)
|
||
startPos = (double)startFrame;
|
||
else
|
||
startPos = (double)(endFrame + 1) - EPSILON; // just under end+1
|
||
```
|
||
|
||
This matches our port's `FrameEpsilon = 1e-5`. The purpose is to ensure
|
||
that `floor(framePos)` is never equal to the next frame on initialization
|
||
— the cursor always starts "just before" its transition boundary.
|
||
|
||
The C `_DAT_007c92b4` value is `1.00000000000000082e-5` (double
|
||
precision). Our `1e-5` match is bit-identical for all practical
|
||
simulation rates.
|
||
|
||
### 6.4 Rate epsilon
|
||
|
||
`_DAT_007c9264` = the minimum-framerate cutoff = `1e-6`. Below this the
|
||
sequencer treats the animation as frozen and doesn't apply
|
||
pos_frame/physics deltas. Our port uses `RateEpsilon = 1e-6`.
|
||
|
||
---
|
||
|
||
## 7 — Animation frame data + part transforms
|
||
|
||
### 7.1 Shape
|
||
|
||
```
|
||
Animation {
|
||
uint32 id (DBObj header)
|
||
uint32 flags (AnimationFlags: PosFrames=0x1)
|
||
uint32 numParts ; # of skeleton bones
|
||
uint32 numFrames
|
||
if (flags & PosFrames)
|
||
Frame[numFrames] posFrames ; root motion: origin+orientation delta per frame
|
||
AnimationFrame[numFrames] ; each has numParts Frames + hook list
|
||
}
|
||
|
||
Frame {
|
||
Vector3 origin
|
||
Quaternion orientation ; x,y,z,w order (IDENTITY = 0,0,0,1)
|
||
}
|
||
|
||
AnimationFrame {
|
||
Frame[numParts] perPartTransform
|
||
uint32 numHooks
|
||
AnimationHook[numHooks]
|
||
}
|
||
```
|
||
|
||
`PosFrames` — when present, they define **root motion**. The first
|
||
part's root slides/rotates frame-to-frame, and this delta is accumulated
|
||
into the `AFrame` (object placement). This is what actually moves the
|
||
character forward during WalkForward — the animation bakes in the
|
||
displacement.
|
||
|
||
### 7.2 How the cursor advances time
|
||
|
||
The sequencer's `update_internal` (ACE `Sequence.cs:351-443`, decompile
|
||
`FUN_005261D0`):
|
||
|
||
```
|
||
frametime = framerate * dt // e.g. 30fps * 0.016s = 0.48 frames
|
||
lastFrame = floor(framePos) // where we were last tick
|
||
framePos += frametime // advance cursor
|
||
|
||
if (frametime > 0):
|
||
if (highFrame < floor(framePos)): // overshoot boundary
|
||
frameOffset = framePos - highFrame - 1 // how far past end
|
||
frameTimeElapsed = frameOffset / framerate // left-over time
|
||
framePos = highFrame
|
||
animDone = true
|
||
while (floor(framePos) > lastFrame):
|
||
if (posFrames present): apply posFrame delta to AFrame
|
||
if (|framerate| > EPSILON): apply_physics(AFrame, 1/framerate, dt)
|
||
execute_hooks(partFrames[lastFrame], Forward)
|
||
lastFrame++
|
||
```
|
||
|
||
Frame advance sequence per tick:
|
||
1. Compute `newFramePos = oldFramePos + framerate*dt`
|
||
2. Walk every integer boundary we crossed (could be multiple if dt large)
|
||
3. For each boundary: add posFrame delta + omega/velocity delta + hooks
|
||
4. If we overshot the node end, advance to next node with remainder time
|
||
|
||
This is a **time-scaled integration**, not framerate-scaled. The actual
|
||
render framerate has no effect on animation speed. `framerate` (in
|
||
frames/sec) controls how fast the cursor moves.
|
||
|
||
### 7.3 The left→right / forward→backward remap
|
||
|
||
AC's MotionTable has **no cycles** for TurnLeft, SideStepLeft, or
|
||
WalkBackwards. These are remapped in `CMotionInterp::adjust_motion`
|
||
(ACE `MotionInterp.cs:394-428`, decompile `FUN_00528C20`):
|
||
|
||
```csharp
|
||
switch (motion) {
|
||
case RunForward: return; // noop
|
||
case WalkBackwards:
|
||
motion = WalkForward;
|
||
speed *= -0.65f; // BackwardsFactor
|
||
break;
|
||
case TurnLeft:
|
||
motion = TurnRight;
|
||
speed *= -1.0f;
|
||
break;
|
||
case SideStepLeft:
|
||
motion = SideStepRight;
|
||
speed *= -1.0f;
|
||
break;
|
||
}
|
||
if (motion == SideStepRight)
|
||
speed *= 0.5f * (3.12f / 1.25f); // SidestepFactor correction
|
||
|
||
if (holdKey == HoldKey.Run)
|
||
apply_run_to_command(ref motion, ref speed); // §8
|
||
```
|
||
|
||
The speed sign-flip propagates through `multiply_framerate` (decompile
|
||
`FUN_005267E0`): any negative framerate swaps `startFrame ↔ endFrame`
|
||
so the advance logic works uniformly:
|
||
|
||
```c
|
||
void multiply_framerate(AnimNode* node, float factor) {
|
||
if (factor < 0.0) {
|
||
// swap startFrame and endFrame
|
||
int tmp = node.startFrame;
|
||
node.startFrame = node.endFrame;
|
||
node.endFrame = tmp;
|
||
}
|
||
node.framerate *= factor;
|
||
}
|
||
```
|
||
|
||
### 7.4 Quaternion interpolation (retail slerp)
|
||
|
||
The decompiled `FUN_005360d0` implements slerp with a special degenerate
|
||
fallback:
|
||
|
||
```c
|
||
float dot = dot(q1, q2);
|
||
if (dot < 0) { q2 = -q2; dot = -dot; } // shorter arc
|
||
if (1-dot <= EPSILON) { // near-parallel
|
||
w1 = 1-t; w2 = t; // linear fallback
|
||
} else {
|
||
omega = acos(dot); sinOmega = sin(omega);
|
||
w1 = sin((1-t)*omega)/sinOmega;
|
||
w2 = sin(t*omega)/sinOmega;
|
||
// Retail-specific: validate results in [0,1]
|
||
if (w1 not in [0,1] || w2 not in [0,1]) {
|
||
w1 = 1-t; w2 = t; // bail to linear
|
||
}
|
||
}
|
||
result = w1*q1 + w2*q2
|
||
```
|
||
|
||
Our port has this — no changes needed.
|
||
|
||
---
|
||
|
||
## 8 — Speed modifiers
|
||
|
||
### 8.1 The speed chain
|
||
|
||
Speed flows through many multipliers before reaching the body:
|
||
|
||
```
|
||
1. Network: client packs RawMotionState.ForwardSpeed (1.0 for walk, 1.5-2x for run with HoldKey)
|
||
2. DoMotion: adjust_motion applies:
|
||
- BackwardsFactor (-0.65) if WalkBackwards
|
||
- Sign flip if TurnLeft/SideStepLeft
|
||
- SidestepFactor (0.5 * 3.12/1.25 = 1.248) if SideStepRight
|
||
- If HoldKey.Run: walk→run conversion + run-rate multiplier
|
||
3. MotionTable.add_motion: velocity = MotionData.Velocity * speedMod
|
||
4. Sequence advance: framerate = AnimData.Framerate * speedMod
|
||
5. PhysicsBody.set_local_velocity: clamp to min(velocity, RunAnimSpeed * MyRunRate)
|
||
```
|
||
|
||
Two constants govern the clamps:
|
||
|
||
| Constant | ACE value | Retail DAT | Purpose |
|
||
|----------|----------:|-----------:|---------|
|
||
| `WalkAnimSpeed` | `3.12f` | `_DAT_007c96e4` | Meters/sec of a 1.0-speed WalkForward |
|
||
| `RunAnimSpeed` | `4.0f` | `_DAT_007c96e0` | Meters/sec of a 1.0-speed RunForward |
|
||
| `SidestepAnimSpeed` | `1.25f` | `_DAT_007c96e8` | Meters/sec of a 1.0-speed SideStepRight |
|
||
| `BackwardsFactor` | `-0.65f` | `_DAT_007c96d8` | Walk-backwards is 65% as fast as forward |
|
||
| `SidestepFactor` | `0.5f` | — | Sidestep defaults to half-speed |
|
||
| `MaxSidestepAnimRate` | `3.0f` | `_DAT_007c96ec` | Clamp on sidestep speed |
|
||
| `RunTurnFactor` | `1.5f` | `_DAT_007c96dc` | Hold-run multiplies turn speed by 1.5 |
|
||
|
||
### 8.2 Run-rate (stamina-weighted)
|
||
|
||
Players have a **RunRate** (0.5–2.0) based on Run skill + Quickness.
|
||
`WeenieObject.InqRunRate(out rate)` returns it. The client caches the
|
||
last-reported value in `MyRunRate` (offset `+0x7C`) so it can continue
|
||
computing speeds if the Weenie is momentarily unreachable.
|
||
|
||
```csharp
|
||
// get_state_velocity (FUN_00528960):
|
||
velocity = Vector3.Zero;
|
||
if (SideStepCommand == SideStepRight)
|
||
velocity.X = SidestepAnimSpeed * SideStepSpeed;
|
||
if (ForwardCommand == WalkForward)
|
||
velocity.Y = WalkAnimSpeed * ForwardSpeed;
|
||
else if (ForwardCommand == RunForward)
|
||
velocity.Y = RunAnimSpeed * ForwardSpeed;
|
||
|
||
rate = WeenieObj.InqRunRate(out _) ? queriedRate : MyRunRate;
|
||
maxSpeed = RunAnimSpeed * rate; // typically 4.0 to 8.0 m/s
|
||
if (|velocity| > maxSpeed)
|
||
velocity = normalize(velocity) * maxSpeed; // clamp diagonal
|
||
```
|
||
|
||
**Key insight:** the clamp is on Euclidean length, not per-axis. This
|
||
is why retail forward+sidestep diagonal isn't faster than pure forward
|
||
at max run rate.
|
||
|
||
### 8.3 Framerate vs time scaling
|
||
|
||
Both animation framerate and physics velocity scale linearly with
|
||
`speedMod`. So running at 2.0× rate:
|
||
- Framerate = `baseFramerate * 2.0` → animation plays 2× fast
|
||
- Velocity = `baseVelocity * 2.0` → body moves 2× fast
|
||
- Result: feet plant at correct spots (no "ice-skating")
|
||
|
||
This is **time-scaled, not framerate-scaled**. The animation playback
|
||
in game loop ticks advances by `framerate * dt` each frame regardless
|
||
of render framerate.
|
||
|
||
---
|
||
|
||
## 9 — Motion errors (WeenieError)
|
||
|
||
From `WeenieError.cs` L148–L193:
|
||
|
||
| Error | Value | Thrown by | Cause |
|
||
|-------|------:|-----------|-------|
|
||
| `None` | `0x00` | — | Success |
|
||
| `NoPhysicsObject` | `0x08` | all CMotionInterp entry points | Object detached / deallocated |
|
||
| `NoAnimationTable` | — | MotionTableManager.PerformMovement | MotionTable wasn't loaded |
|
||
| `NoMtableData` | `0x0043` | MotionTableManager | Cycle/link not found in table |
|
||
| `CantCrouchInCombat` | `0x003F` | DoMotion when in non-NonCombat style | Crouch attempted during combat |
|
||
| `CantSitInCombat` | `0x0040` | DoMotion, similar | Sit attempted |
|
||
| `CantLieDownInCombat` | `0x0041` | DoMotion | Sleep attempted |
|
||
| `CantChatEmoteInCombat` | `0x0042` | DoMotion (`motion & ChatEmote != 0`) | Wave/etc. in combat |
|
||
| `CantChatEmoteNotStanding` | `0x0044` | DoMotion indirectly | Emote while sitting/crouched |
|
||
| `TooManyActions` | `0x0045` | DoMotion action-path (>6 queued) | Action queue overflow |
|
||
| `Hidden` | `0x0046` | (not motion-owned) | Misc |
|
||
| `GeneralMovementFailure` | `0x0047` | Fall-through default | Switch default branch |
|
||
| `YouCantJumpFromThisPosition` | `0x0048` | motion_allows_jump, jump_is_allowed | Crouching, reading, casting, etc. |
|
||
| `CantJumpLoadedDown` | `0x0049` | jump_is_allowed | WeenieObj.CanJump returned false |
|
||
|
||
Additional **action-state errors** from DoInterpretedMotion (decompile
|
||
`FUN_00528f70` L6967–L7005):
|
||
|
||
| Code | Motion checked | Meaning |
|
||
|-----:|---------------|---------|
|
||
| `0x3F` | Crouch (`0x41000012`) while in combat | Same as CantCrouchInCombat |
|
||
| `0x40` | Sitting (`0x41000013`) while in combat | Same as CantSitInCombat |
|
||
| `0x41` | Sleeping (`0x41000014`) while in combat | Same as CantLieDownInCombat |
|
||
| `0x42` | ChatEmote bit (`0x02000000`) set while in combat | Same as CantChatEmoteInCombat |
|
||
| `0x45` | ActionMask bit (`0x10000000`) set and `num_actions >= 6` | Action queue full |
|
||
|
||
The action queue limit of **6** is a retail invariant — see
|
||
`FUN_00529930:7614 if (5 < uVar3) return 0x45`. This means you can
|
||
line up a 3-slash combo + cast start, but a 7th action errors.
|
||
|
||
---
|
||
|
||
## 10 — Network motion echoes (UpdateMotion)
|
||
|
||
### 10.1 Wire shape of `GameMessageUpdateMotion` (opcode 0xF748 / Motion)
|
||
|
||
Sent by server whenever any object's motion state changes. From
|
||
ACE `GameMessageUpdateMotion.cs` + `MovementData.cs` + `InterpretedMotionState.cs`:
|
||
|
||
```
|
||
GameMessage {
|
||
u32 opcode = 0xF748 ; Motion group=SmartboxQueue
|
||
ObjectGuid guid ; 4 bytes
|
||
u16 objectInstanceSequence ; ordering counter
|
||
u16 movementSequence ; incremented every Motion update
|
||
u16 serverControlSequence ; if IsAutonomous, use current; else next
|
||
u8 isAutonomous ; 1=client requested, 0=server-pushed
|
||
align(4)
|
||
u8 movementType ; enum: 0=Invalid..9=TurnToHeading
|
||
u8 motionFlags ; 0x01=StickToObject, 0x02=StandingLongJump
|
||
u16 currentStyle ; truncated to 16 bits (0x003D for NonCombat)
|
||
|
||
switch (movementType) {
|
||
case Invalid: InterpretedMotionState state
|
||
if (motionFlags & 0x01) ObjectGuid stickyObject
|
||
case MoveToObject: ObjectGuid target; Origin origin; MoveToParameters; f32 runRate
|
||
case MoveToPosition: Origin; MoveToParameters; f32 runRate
|
||
case TurnToObject: ObjectGuid target; f32 desiredHeading; TurnToParameters
|
||
case TurnToHeading: TurnToParameters
|
||
}
|
||
}
|
||
|
||
InterpretedMotionState {
|
||
u32 packedFlagsAndCount ; low 7 bits = MovementStateFlag, upper 25 = numCommands
|
||
if (flag & CurrentStyle) u16 currentStyle
|
||
if (flag & ForwardCommand) u16 forwardCommand ; low 16 bits of MotionCommand
|
||
if (flag & SideStepCommand) u16 sidestepCommand
|
||
if (flag & TurnCommand) u16 turnCommand
|
||
if (flag & ForwardSpeed) f32 forwardSpeed
|
||
if (flag & SideStepSpeed) f32 sidestepSpeed
|
||
if (flag & TurnSpeed) f32 turnSpeed
|
||
MotionItem[numCommands] ; each is 8 bytes: u16 cmd + u16 packedSeq + f32 speed
|
||
align(4)
|
||
}
|
||
|
||
MotionItem { ; one entry in the "Commands" queue
|
||
u16 command
|
||
u16 packedSequence ; bit 15: isAutonomous, bits 0-14: sequence
|
||
f32 speed
|
||
}
|
||
|
||
MovementStateFlag { ; 7 bits
|
||
CurrentStyle = 0x01
|
||
ForwardCommand = 0x02
|
||
ForwardSpeed = 0x04
|
||
SideStepCommand= 0x08
|
||
SideStepSpeed = 0x10
|
||
TurnCommand = 0x20
|
||
TurnSpeed = 0x40
|
||
}
|
||
|
||
Origin { u32 cellId; f32 x,y,z }
|
||
MoveToParameters { u32 flags; f32 distanceToObject, minDistance, failDistance, speed, walkRunThreshold, desiredHeading }
|
||
TurnToParameters { u32 flags; f32 speed, desiredHeading }
|
||
```
|
||
|
||
### 10.2 Client broadcasts vs local simulation
|
||
|
||
The client maintains a **RawMotionState** (what it *wants* to do —
|
||
driven by keypresses) and an **InterpretedMotionState** (what the
|
||
sequencer should *actually* play). The server receives
|
||
`MoveToState` packets carrying the raw state, interprets them into
|
||
`InterpretedMotionState` via `MovementData(Creature, MoveToState)`
|
||
(ACE `:87-165`), and broadcasts `UpdateMotion` to observers.
|
||
|
||
**Client simulation loop:**
|
||
1. Input thread sets `RawMotionState.ForwardCommand = WalkForward` + `HoldKey.Run`
|
||
2. `CMotionInterp::apply_raw_movement` calls `adjust_motion` → `WalkForward + Run` becomes `RunForward` with speed ×runRate
|
||
3. Animation sequencer starts the `(NonCombat, RunForward)` cycle
|
||
4. Physics body's local velocity = `RunAnimSpeed * runRate`
|
||
5. Client sends `0xF61B MoveToState` packet with the *raw* state
|
||
6. Server validates, emits `0xF748 UpdateMotion` with interpreted state to nearby observers
|
||
7. Observers' clients receive UpdateMotion, apply via `move_to_interpreted_state`
|
||
|
||
**Observation of own player:** When the server broadcasts *my own*
|
||
motion back to me, I compare the received sequence to my local one via
|
||
`action.Stamp` (15-bit ring counter at offset `+0x78`). If server is
|
||
ahead of me, I catch up; otherwise I ignore. See `FUN_005295D0` L7363–L7388.
|
||
|
||
### 10.3 The RawMotionState → InterpretedMotionState conversion
|
||
|
||
This is the **server's** job, but it's worth understanding:
|
||
|
||
```csharp
|
||
// MovementData.cs:87-165
|
||
var holdKey = rawState.CurrentHoldKey;
|
||
var speed = (holdKey == HoldKey.Run) ? creature.GetRunRate() : 1.0f;
|
||
if (rawState.ForwardCommand == WalkForward || WalkBackwards) {
|
||
interp.ForwardCommand = (holdKey==Run && WalkForward) ? RunForward : WalkForward;
|
||
interp.ForwardSpeed = speed;
|
||
if (WalkBackwards) interp.ForwardSpeed *= -0.65f;
|
||
}
|
||
if (rawState.SidestepCommand != 0) {
|
||
interp.SidestepCommand = SideStepRight;
|
||
interp.SidestepSpeed = speed * (3.12/1.25) * 0.5 * (LeftSign);
|
||
interp.SidestepSpeed = clamp(-3, 3);
|
||
}
|
||
if (rawState.TurnCommand != 0) {
|
||
interp.TurnCommand = TurnRight;
|
||
interp.TurnSpeed = (Run)?1.5:1.0 * TurnSign;
|
||
// Mouselook override: if rawState.TurnSpeed <= 1.5 use it verbatim
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11 — Audio-motion coupling
|
||
|
||
### 11.1 Footstep sounds
|
||
|
||
**Per-surface footsteps** are NOT encoded by the Sound hook directly —
|
||
they use `SoundTable` hooks (`AnimationHookType.SoundTable = 0x02`)
|
||
which carry a `SoundType` selector. The engine then looks up the
|
||
actual Wave id from the creature's `PhysicsObj.SoundTable`, keyed by
|
||
the surface material of the cell the creature is standing on.
|
||
|
||
So `WalkForward` has two `SoundTable` hooks at the foot-plant frames
|
||
(frames 5 and 15 of a ~20-frame cycle typically). The SoundType might
|
||
be `StepLeft` / `StepRight`, and the resolved wave depends on whether
|
||
you're on grass (dirt+leaves whoosh), stone (metallic clack), water
|
||
(splash), etc.
|
||
|
||
Surface-material lookup is outside R3 scope (lives in R9 terrain
|
||
system), but the attack/motion side is: **the hooks fire at fixed
|
||
frame indexes regardless of surface**, then the audio subsystem picks
|
||
the right wave.
|
||
|
||
### 11.2 Attack whoosh
|
||
|
||
Melee attacks have a `Sound` hook (not SoundTable) firing 2–3 frames
|
||
*before* the `Attack` hook — i.e. the wind-up whoosh precedes the
|
||
damage frame. The same Wave id is used regardless of weapon (it's
|
||
material-agnostic "swoosh"). Weapon-specific sounds (metal clang on
|
||
hit) fire from the server side via `GameMessageHearSound` after
|
||
damage resolves.
|
||
|
||
### 11.3 Armor rustle
|
||
|
||
An optional layer driven by `SoundTable` hooks on *every* animation
|
||
frame cycle where `RawMotionFlags.ArmorEncumbrance` was set on the
|
||
body. This is throttled client-side so you don't hear 60Hz rustling.
|
||
There is no per-frame hook for this; it's a PhysicsObj post-tick
|
||
callback.
|
||
|
||
---
|
||
|
||
## 12 — Complete pseudocode for `DoInterpretedMotion` and friends
|
||
|
||
### 12.1 DoInterpretedMotion (FUN_00528F70) — expanded
|
||
|
||
```
|
||
int DoInterpretedMotion(CMotionInterp* self, uint motion, MovementParameters* params) {
|
||
if (!self->physicsObj) return 0x08; // NoPhysicsObject
|
||
int allowed = contact_allows_move(motion);
|
||
if (!allowed) {
|
||
if (motion & 0x10000000) // Action bit
|
||
return 0x24; // YouCantJumpWhileInTheAir (0x48 in ACE)
|
||
// Non-action motion is silently accepted (state updates, no anim)
|
||
} else if (self->standingLongJump && (motion==WalkForward || motion==RunForward || motion==SideStepRight)) {
|
||
// No-op: standing long jump blocks all ground motion
|
||
} else {
|
||
if (motion == Dead) self->physicsObj->RemoveLinkAnimations();
|
||
int result = self->physicsObj->DoInterpretedMotion(motion, params);
|
||
if (result == 0) {
|
||
int jumpErr;
|
||
if (params->flags & 0x20000) // DisableJumpDuringLink
|
||
jumpErr = 0x48; // YouCantJumpFromThisPosition
|
||
else {
|
||
jumpErr = motion_allows_jump(motion);
|
||
if (jumpErr == 0 && !(motion & 0x10000000))
|
||
jumpErr = motion_allows_jump(self->interpretedState.ForwardCommand);
|
||
}
|
||
add_to_queue(params->contextId, motion, jumpErr);
|
||
if (params->flags & 0x4000) // ModifyInterpretedState
|
||
InterpretedState_ApplyMotion(&self->interpretedState, motion, params);
|
||
}
|
||
}
|
||
if (self->physicsObj && self->physicsObj->curCell == NULL)
|
||
self->physicsObj->RemoveLinkAnimations();
|
||
return result;
|
||
}
|
||
```
|
||
|
||
### 12.2 adjust_motion (FUN_00528C20) — left/right/backward remap
|
||
|
||
```
|
||
void adjust_motion(uint* motion, float* speed, int holdKey) {
|
||
if (self->weenieObj != NULL && !self->weenieObj->IsCreature()) return;
|
||
switch (*motion) {
|
||
case 0x44000007: return; // RunForward: no-op
|
||
case 0x45000006: // WalkBackwards → WalkForward reversed
|
||
*motion = 0x45000005; *speed *= -0.65f; break;
|
||
case 0x6500000E: // TurnLeft → TurnRight reversed
|
||
*motion = 0x6500000D; *speed *= -1.0f; break;
|
||
case 0x65000010: // SideStepLeft → SideStepRight reversed
|
||
*motion = 0x6500000F; *speed *= -1.0f; break;
|
||
}
|
||
if (*motion == 0x6500000F) // SideStepRight: apply sidestep factor
|
||
*speed *= 0.5f * (3.12f / 1.25f); // = 1.248
|
||
if (holdKey == HoldKey.Invalid)
|
||
holdKey = self->rawState.currentHoldKey;
|
||
if (holdKey == HoldKey.Run)
|
||
apply_run_to_command(motion, speed);
|
||
}
|
||
```
|
||
|
||
### 12.3 apply_run_to_command (FUN_005287F0)
|
||
|
||
```
|
||
void apply_run_to_command(uint* motion, float* speed) {
|
||
float rate = DEFAULT_RUN_RATE;
|
||
if (self->weenieObj) {
|
||
float r;
|
||
if (self->weenieObj->InqRunRate(&r)) rate = r;
|
||
else rate = self->myRunRate;
|
||
}
|
||
switch (*motion) {
|
||
case 0x45000005: // WalkForward + Run = RunForward
|
||
if (*speed > 0) *motion = 0x44000007;
|
||
*speed *= rate; break;
|
||
case 0x6500000D: // TurnRight + Run
|
||
*speed *= 1.5f; // RunTurnFactor
|
||
break;
|
||
case 0x6500000F: // SideStepRight + Run
|
||
*speed *= rate;
|
||
if (|*speed| > 3.0f) *speed = sign(*speed) * 3.0f;
|
||
break;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 12.4 PerformMovement (FUN_00529A90) top-level
|
||
|
||
```
|
||
int PerformMovement(CMotionInterp* self, MovementStruct* mvs) {
|
||
int result;
|
||
switch (mvs->type) {
|
||
case 1: result = DoMotion(mvs->motion, &mvs->params); break;
|
||
case 2: result = DoInterpretedMotion(mvs->motion, &mvs->params); break;
|
||
case 3: result = StopMotion(mvs->motion, &mvs->params); break;
|
||
case 4: result = StopInterpretedMotion(mvs->motion, &mvs->params);break;
|
||
case 5: result = StopCompletely(); break;
|
||
default: return 0x47; // GeneralMovementFailure
|
||
}
|
||
self->physicsObj->CheckForCompletedMotions(); // drain PendingMotions queue
|
||
return result;
|
||
}
|
||
```
|
||
|
||
### 12.5 GetObjectSequence — the motion table walker
|
||
|
||
```
|
||
bool GetObjectSequence(MotionTable* mt, uint motion, MotionState* state,
|
||
Sequence* seq, float speedMod, uint* numAnims, bool stopMods) {
|
||
*numAnims = 0;
|
||
if (state->style == 0 || state->substate == 0) return false;
|
||
|
||
uint styleDefault = mt->styleDefaults[state->style];
|
||
if (motion == styleDefault && !stopMods && (state->substate & Modifier)) return true;
|
||
|
||
// === STYLE CHANGE PATH ===
|
||
if (motion & Style) {
|
||
if (state->style == motion) return true;
|
||
MotionData* fromLink = NULL;
|
||
if (styleDefault != state->substate)
|
||
fromLink = get_link(state->style, state->substate, state->substateMod, styleDefault, speedMod);
|
||
if (styleDefault != 0) {
|
||
MotionData* cycles = mt->cycles[(motion<<16)|styleDefault];
|
||
if (cycles) {
|
||
if (cycles->bitfield & 1) state->clear_modifiers();
|
||
MotionData* connectorLink = get_link(state->style, styleDefault, state->substateMod, motion, speedMod);
|
||
MotionData* secondLink = NULL;
|
||
if (!connectorLink && state->style != motion) {
|
||
connectorLink = get_link(state->style, styleDefault, 1.0f, mt->defaultStyle, 1.0f);
|
||
secondLink = get_link(mt->defaultStyle, mt->styleDefaults[mt->defaultStyle], 1.0f, motion, 1.0f);
|
||
}
|
||
seq->clear_physics();
|
||
seq->remove_cyclic_anims();
|
||
add_motion(seq, fromLink, speedMod);
|
||
add_motion(seq, connectorLink, speedMod);
|
||
add_motion(seq, secondLink, speedMod);
|
||
add_motion(seq, cycles, speedMod);
|
||
state->substate = styleDefault;
|
||
state->style = motion;
|
||
state->substateMod = speedMod;
|
||
re_modify(seq, state);
|
||
*numAnims = totalAnimsAcrossAllFour - 1;
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// === SUBSTATE CHANGE PATH (Walk → Run etc.) ===
|
||
if (motion & SubState) {
|
||
uint motionID = motion & 0xFFFFFF;
|
||
MotionData* target = mt->cycles[(state->style<<16)|motionID];
|
||
if (!target) target = mt->cycles[(mt->defaultStyle<<16)|motionID];
|
||
if (target && is_allowed(motion, target, state)) {
|
||
if (motion == state->substate && seq->HasAnims() &&
|
||
sign(speedMod) == sign(state->substateMod)) {
|
||
// Same command, just tweak speed
|
||
change_cycle_speed(seq, target, state->substateMod, speedMod);
|
||
subtract_motion(seq, target, state->substateMod);
|
||
combine_motion(seq, target, speedMod);
|
||
state->substateMod = speedMod;
|
||
return true;
|
||
}
|
||
if (target->bitfield & 1) state->clear_modifiers();
|
||
MotionData* link = get_link(state->style, state->substate, state->substateMod, motion, speedMod);
|
||
MotionData* altLink = NULL;
|
||
if (!link || sign(speedMod)!=sign(state->substateMod)) {
|
||
uint def = mt->styleDefaults[state->style];
|
||
link = get_link(state->style, state->substate, state->substateMod, def, 1.0f);
|
||
altLink = get_link(state->style, def, 1.0f, motion, speedMod);
|
||
}
|
||
seq->clear_physics();
|
||
seq->remove_cyclic_anims();
|
||
if (altLink) {
|
||
add_motion(seq, link, state->substateMod);
|
||
add_motion(seq, altLink, speedMod);
|
||
} else {
|
||
float ns = (state->substateMod < 0 && speedMod > 0) ? -speedMod : speedMod;
|
||
add_motion(seq, link, ns);
|
||
}
|
||
add_motion(seq, target, speedMod);
|
||
if (state->substate != motion && (state->substate & Modifier)) {
|
||
uint def = mt->styleDefaults[state->style];
|
||
if (def != motion) state->add_modifier_no_check(state->substate, state->substateMod);
|
||
}
|
||
state->substateMod = speedMod;
|
||
state->substate = motion;
|
||
re_modify(seq, state);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// === ACTION PATH (one-shot) ===
|
||
if (motion & Action) {
|
||
uint cycleKey = (state->style<<16) | (state->substate & 0xFFFFFF);
|
||
MotionData* cycles = mt->cycles[cycleKey];
|
||
if (cycles) {
|
||
MotionData* link = get_link(state->style, state->substate, state->substateMod, motion, speedMod);
|
||
if (link) {
|
||
state->add_action(motion, speedMod);
|
||
seq->clear_physics();
|
||
seq->remove_cyclic_anims();
|
||
add_motion(seq, link, speedMod);
|
||
add_motion(seq, cycles, state->substateMod);
|
||
re_modify(seq, state);
|
||
return true;
|
||
}
|
||
// ... fallback via defaultStyleSub path (lines 209-230 of MotionTable.cs)
|
||
}
|
||
}
|
||
|
||
// === MODIFIER PATH (overlay) ===
|
||
if (motion & Modifier) {
|
||
MotionData* base = mt->cycles[(state->style<<16) | (state->substate & 0xFFFFFF)];
|
||
if (base && !(base->bitfield & 1)) {
|
||
MotionData* mod = mt->modifiers[(state->style<<16)|(motion & 0xFFFFFF)];
|
||
if (!mod) mod = mt->modifiers[motion & 0xFFFFFF]; // style-agnostic fallback
|
||
if (mod) {
|
||
if (!state->add_modifier(motion, speedMod)) {
|
||
StopSequenceMotion(motion, 1.0f, state, seq, numAnims);
|
||
if (!state->add_modifier(motion, speedMod)) return false;
|
||
}
|
||
combine_motion(seq, mod, speedMod); // ADD velocities, don't replace
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13 — Port plan (extensions to MotionInterpreter / AnimationSequencer)
|
||
|
||
### 13.1 Gaps vs current port
|
||
|
||
Current `MotionInterpreter` is ~660 LOC and covers:
|
||
- RawMotionState, InterpretedMotionState structs ✅
|
||
- PerformMovement switch-5 ✅
|
||
- DoMotion/DoInterpretedMotion (partial — no CantCrouch etc.) ✅
|
||
- StopMotion/StopInterpretedMotion/StopCompletely ✅
|
||
- get_state_velocity with max-speed clamp ✅
|
||
- apply_current_movement (grounded only) ✅
|
||
- Jump chain (jump, jump_is_allowed, get_leave_ground_velocity, LeaveGround, HitGround) ✅
|
||
|
||
Current `AnimationSequencer` covers:
|
||
- SetCycle with left→right remap ✅
|
||
- Enqueue link + cyclic nodes, advance time, wrap to firstCyclic ✅
|
||
- Retail slerp ✅
|
||
- BUT: treats MotionData as mono-cycle. No style-transition chain, no
|
||
modifiers, no actions, no attack hooks, no sound hooks.
|
||
|
||
### 13.2 Extensions required for R3
|
||
|
||
**`MotionInterpreter.cs` additions:**
|
||
|
||
1. `public MotionState MotionState` (style/substate/substateMod + modifier list + action list)
|
||
2. `public enum HoldKey { Invalid, None, Run }` + `RawState.CurrentHoldKey` field
|
||
3. `CombatStyleGuards` in `DoMotion`:
|
||
```csharp
|
||
if (InterpretedState.CurrentStyle != NonCombat) {
|
||
switch(motion) {
|
||
case Crouch: return CantCrouchInCombat;
|
||
case Sitting: return CantSitInCombat;
|
||
case Sleeping: return CantLieDownInCombat;
|
||
}
|
||
if ((motion & ChatEmote) != 0) return CantChatEmoteInCombat;
|
||
}
|
||
```
|
||
4. `ActionQueueLimit`: track `numActions`; throw `TooManyActions` at 6.
|
||
5. `adjust_motion` full port (currently only in AnimationSequencer.SetCycle — pull it up into MotionInterpreter so DoMotion/StopMotion both use it).
|
||
6. `apply_run_to_command` — handle HoldKey.Run + walk→run conversion + sidestep clamp.
|
||
7. `SetHoldKey(HoldKey k, bool cancelMoveTo)` + `set_hold_run(int, bool)`.
|
||
8. `motion_allows_jump(uint substate)` — the ranged-check helper:
|
||
```csharp
|
||
// Reload..Pickup, TripleThrust..MagicPowerUp10Purple, MagicPowerUp01..10,
|
||
// Crouch..Sleeping, AimLevel..MagicPray, Falling → YouCantJumpFromThisPosition
|
||
```
|
||
9. `move_to_interpreted_state(InterpretedMotionState state)` — receive
|
||
and replay the server's authoritative state, including action-stamp
|
||
comparison logic from `FUN_005295D0`.
|
||
10. `enter_default_state()` and `HandleEnterWorld()/HandleExitWorld()`.
|
||
|
||
**`AnimationSequencer.cs` additions:**
|
||
|
||
1. **`EnqueueStyleTransition(uint oldStyle, uint oldSub, uint newStyle, float speed)`** — the multi-link chain from §4.
|
||
2. **`AddModifier(uint modCommand, float speed)` / `RemoveModifier`** — maintain the overlay list. When a modifier is active, its `MotionData.Velocity` and `Omega` are *added* to the base cycle's (not replaced). See `CombinePhysics` / `subtract_physics` in ACE Sequence.cs.
|
||
3. **`AddAction(uint actionCommand, float speed)`** — queue a one-shot
|
||
link+cycle pair. The action plays to completion then pops.
|
||
4. **Per-frame hook dispatch** — populate a `PendingHooks: List<AnimationHook>` each tick. Callers consume it via a new `IReadOnlyList<AnimationHook> ConsumePendingHooks()` method.
|
||
5. **`PosFrames` integration** — if the current Animation has PosFrames,
|
||
apply the per-frame origin+orientation delta to a running AFrame
|
||
(exposed as a `PartTransform` on "part -1" or a separate `RootMotion` property).
|
||
6. **Speed-change mid-cycle** — when the same command is re-issued with a
|
||
different speed, don't restart; call `multiply_cyclic_animation_framerate(newSpeed/oldSpeed)` — see `MotionTable.change_cycle_speed`. Currently `SetCycle`'s fast-path returns early for same motion but doesn't handle speed changes.
|
||
|
||
**New classes:**
|
||
|
||
- `MotionState` — mirror of ACE's (Style, Substate, SubstateMod, Modifiers, Actions).
|
||
- `Motion` (sequence node): `{ uint ID; float SpeedMod }` for modifier/action tracking.
|
||
- `MotionTableWalker` — a new type that owns the GetObjectSequence logic, separate from both MotionInterpreter (which is physics-focused) and AnimationSequencer (which is time-stepper-focused). Mirror of ACE's `MotionTable` + `MotionTableManager`.
|
||
- `AttackHook`/`SoundHook`/`SoundTableHook` types (already in DatReaderWriter; just need our own switch dispatch).
|
||
|
||
**Wire layer (R10 adjacent but referenced here):**
|
||
|
||
- `MovementData` reader/writer matching ACE's layout byte-for-byte.
|
||
- `InterpretedMotionState` + `RawMotionState` + `MotionItem` codecs — holtburger has working Rust implementations to mirror.
|
||
- Server→client `0xF748 Motion` handler that constructs `MovementStruct { type=2, motion=interp.ForwardCommand, ... }` and calls `MotionInterpreter.PerformMovement`.
|
||
|
||
---
|
||
|
||
## 14 — Conformance test golden cases
|
||
|
||
1. **Cycle key for (NonCombat, WalkForward) = `0x003D0005`**
|
||
```csharp
|
||
Assert.Equal(0x003D0005, MotionTable.MakeCycleKey(MotionStance.NonCombat, MotionCommand.WalkForward));
|
||
```
|
||
|
||
2. **Cycle key for (Magic, MeditateState) = `0x0049011C`**
|
||
```csharp
|
||
Assert.Equal(0x0049011C, MotionTable.MakeCycleKey(MotionStance.Magic, MotionCommand.MeditateState));
|
||
```
|
||
|
||
3. **TurnLeft remaps to TurnRight with speed ×-1**
|
||
```csharp
|
||
uint motion = (uint)MotionCommand.TurnLeft; float speed = 1.0f;
|
||
interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
|
||
Assert.Equal((uint)MotionCommand.TurnRight, motion);
|
||
Assert.Equal(-1.0f, speed);
|
||
```
|
||
|
||
4. **WalkBackwards remaps to WalkForward ×-0.65**
|
||
```csharp
|
||
uint motion = (uint)MotionCommand.WalkBackwards; float speed = 1.0f;
|
||
interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
|
||
Assert.Equal((uint)MotionCommand.WalkForward, motion);
|
||
Assert.Equal(-0.65f, speed);
|
||
```
|
||
|
||
5. **Hold-Run + WalkForward becomes RunForward × runRate**
|
||
```csharp
|
||
uint motion = (uint)MotionCommand.WalkForward; float speed = 1.0f;
|
||
interp.MyRunRate = 1.5f;
|
||
interp.AdjustMotion(ref motion, ref speed, HoldKey.Run);
|
||
Assert.Equal((uint)MotionCommand.RunForward, motion);
|
||
Assert.Equal(1.5f, speed, 1e-6);
|
||
```
|
||
|
||
6. **SideStepRight applies SidestepFactor**
|
||
```csharp
|
||
uint motion = (uint)MotionCommand.SideStepRight; float speed = 1.0f;
|
||
interp.AdjustMotion(ref motion, ref speed, HoldKey.None);
|
||
Assert.Equal(1.248f, speed, 1e-3); // 0.5 * 3.12 / 1.25
|
||
```
|
||
|
||
7. **Seven actions queued → TooManyActions on 7th**
|
||
```csharp
|
||
var interp = MakeInterp();
|
||
for (int i = 0; i < 6; i++)
|
||
Assert.Equal(WeenieError.None, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters()));
|
||
Assert.Equal(WeenieError.TooManyActions, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters()));
|
||
```
|
||
|
||
8. **Crouch in SwordCombat fails**
|
||
```csharp
|
||
interp.InterpretedState.CurrentStyle = (uint)MotionStance.SwordCombat;
|
||
Assert.Equal(WeenieError.CantCrouchInCombat,
|
||
interp.DoMotion(MotionCommand.Crouch, new MovementParameters()));
|
||
```
|
||
|
||
9. **Jump while airborne fails**
|
||
```csharp
|
||
phys.TransientState &= ~TransientStateFlags.OnWalkable;
|
||
Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, interp.Jump(1.0f, 0));
|
||
```
|
||
|
||
10. **Wave emote in NonCombat succeeds**
|
||
```csharp
|
||
interp.InterpretedState.CurrentStyle = (uint)MotionStance.NonCombat;
|
||
Assert.Equal(WeenieError.None,
|
||
interp.DoMotion(MotionCommand.Wave, new MovementParameters()));
|
||
```
|
||
|
||
11. **AnimationSequencer.SetCycle with speed=0 doesn't advance**
|
||
```csharp
|
||
seq.SetCycle(NonCombat, RunForward, speedMod: 0f);
|
||
var pre = seq.Advance(0.016f);
|
||
var post = seq.Advance(0.016f);
|
||
AssertAllTransformsEqual(pre, post);
|
||
```
|
||
|
||
12. **Negative framerate reverses playback** (golden from decompiled multiply_framerate):
|
||
```csharp
|
||
var node = new AnimNode(anim, framerate: -30f, startFrame: 0, endFrame: 10, isLooping: true);
|
||
// After multiply_framerate, cursor should start near endFrame+1 - ε
|
||
Assert.True(node.GetStartFramePosition() > 10.0 - 1e-3);
|
||
```
|
||
|
||
13. **MotionData.Velocity scales linearly with speedMod**
|
||
```csharp
|
||
var md = new MotionData { Velocity = new Vector3(0, 4, 0) }; // 4 m/s forward
|
||
seq.AddMotion(md, speedMod: 0.5f);
|
||
Assert.Equal(new Vector3(0, 2, 0), seq.Velocity);
|
||
```
|
||
|
||
14. **Attack hook fires exactly once per animation cycle**
|
||
```csharp
|
||
seq.SetCycle(SwordCombat, SlashHigh, speedMod: 1f);
|
||
var hitCount = 0;
|
||
for (int i = 0; i < 100; i++) {
|
||
seq.Advance(0.016f);
|
||
hitCount += seq.ConsumePendingHooks().Count(h => h is AttackHook);
|
||
}
|
||
Assert.Equal(1, hitCount);
|
||
```
|
||
|
||
---
|
||
|
||
## 15 — Cross-cut notes
|
||
|
||
- **Thread-safety:** `MotionTable` is read-only after unpack; walking it
|
||
is safe from any thread. `Sequence` and `MotionInterpreter` are
|
||
per-entity and expected to run on the sim thread. The client uses
|
||
a single dedicated thread for all motion updates (see chunk_00450000.c).
|
||
|
||
- **Memory layout sensitivity:** `CMotionInterp` struct offsets are
|
||
cited throughout (`+0x20` ForwardCommand, `+0x4C` interp-ForwardCommand,
|
||
`+0x70` StandingLongJump, `+0x74` JumpExtent, `+0x78` ActionStamp,
|
||
`+0x7C` MyRunRate). Our C# port doesn't need to preserve these but
|
||
the decompiled references do, and when debugging from raw memory
|
||
dumps they're useful.
|
||
|
||
- **PendingMotions queue:** Each `DoInterpretedMotion` success adds a
|
||
`MotionNode { contextID, motion, jumpErrorCode }` to the
|
||
PendingMotions linked list. This list drains when the sequencer
|
||
reports the animation has *fully* played (via `HookObj.add_anim_hook(AnimationDone)`).
|
||
Until then, `physicsObj->IsAnimating = true` and `motions_pending() == true`.
|
||
|
||
- **Link-removal optimization:** `MotionTableManager.remove_redundant_links`
|
||
scans the PendingAnimations list; when two consecutive entries have
|
||
the same motion ID (e.g. double-tap of WalkForward), it truncates
|
||
the older one by setting its NumAnims to 0 and calling
|
||
`sequence.remove_link_animations(totalAnims)`. This is how
|
||
rapid-fire key-mashing doesn't pile up.
|
||
|
||
---
|
||
|
||
## References cited
|
||
|
||
- `docs/research/decompiled/chunk_00520000.c`:
|
||
L3080–L3117 (PerformMovement switch),
|
||
L4368–L4677 (update_internal + advance_to_next_animation),
|
||
L4698–L4784 (multiply_framerate, GetStart/EndFramePosition),
|
||
L6433–L6504 (add_to_queue, apply_run_to_command),
|
||
L6565–L6611 (get_state_velocity),
|
||
L6616–L6641 (StopCompletely),
|
||
L6750–L6800 (adjust_motion),
|
||
L6803–L6955 (get_leave_ground_velocity),
|
||
L6955–L7004 (DoInterpretedMotion),
|
||
L7045–L7088 (StopMotion),
|
||
L7132–L7189 (apply_current_movement),
|
||
L7194–L7213 (jump),
|
||
L7217–L7235 (apply_raw_movement),
|
||
L7341–L7391 (move_to_interpreted_state / FUN_005295D0),
|
||
L7395–L7441 (HitGround / LeaveGround),
|
||
L7554–L7622 (DoMotion),
|
||
L7627–L7658 (PerformMovement dispatcher).
|
||
|
||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`:
|
||
L51–L110 DoInterpretedMotion, L112–L158 DoMotion, L236–L262 PerformMovement,
|
||
L301–L327 StopCompletely, L394–L428 adjust_motion, L430–L504 apply_interpreted_movement,
|
||
L506–L523 apply_raw_movement, L525–L562 apply_run_to_command, L678–L700 get_state_velocity,
|
||
L710–L727 jump, L729–L779 jump_is_allowed / motion_allows_jump.
|
||
|
||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs`:
|
||
L55–L257 GetObjectSequence (style/substate/action/modifier paths),
|
||
L266–L291 SetDefaultState, L293–L356 StopObject*, L358–L393 add_motion/combine/subtract,
|
||
L395–L426 get_link, L428–L438 is_allowed, L440–L458 re_modify.
|
||
|
||
- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs`:
|
||
L83–L87 CombinePhysics, L145–L201 advance_to_next_animation,
|
||
L203–L216 append_animation, L218–L230 apply_physics, L232–L243 apricot,
|
||
L262–L270 execute_hooks, L277–L287 multiply_cyclic_animation_framerate,
|
||
L351–L443 update_internal (time stepper).
|
||
|
||
- `references/DatReaderWriter/DatReaderWriter/Generated/`:
|
||
`DBObjs/MotionTable.generated.cs` (L27–L57 schema fields),
|
||
`DBObjs/Animation.generated.cs` (L27–L68),
|
||
`Types/MotionData.generated.cs`, `Types/AnimData.generated.cs`,
|
||
`Types/AnimationFrame.generated.cs`, `Types/AnimationHook.generated.cs` (L45–L128 polymorphic unpack),
|
||
`Enums/AnimationHookType.generated.cs`, `Enums/MotionDataFlags.generated.cs`,
|
||
`Enums/MotionCommand.generated.cs` (the full 450+ entry command catalog).
|
||
|
||
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/`:
|
||
`messages/motion.rs` (MovementEventData wire layout),
|
||
`types.rs` (InterpretedMotionState, RawMotionState, MotionItem codec).
|
||
|
||
- `references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs`,
|
||
`InterpretedMotionState.cs` (server-side writer).
|
||
|
||
- `references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs` (Style/SubState/etc. bits),
|
||
`references/ACE/Source/ACE.Entity/Enum/WeenieError.cs` L148–L193 (error codes).
|