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.
65 KiB
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.cL4368–L4694 —Sequence::update_internal,Sequence::advance_to_next_animation, andmultiply_framerate(the retail animation time stepper)docs/research/decompiled/chunk_005B0000.c— peripheral STL containers around MotionTable + the motion-command switch surfacesreferences/ACE/Source/ACE.Server/Physics/Animation/— 20-file C# port, the closest live interpretation of the decompilereferences/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 treereferences/holtburger/crates/holtburger-protocol/src/messages/movement/— authoritative client-side wire shape forMoveToState,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:85ACE:Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);— themotionthere is still the style (bits 0x80000000 set) but ACE only uses the low 16 bits.MotionTable.cs:191ACE: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 | substatewith 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(ACEMotionTable.cs:362), which directly adds to the AFrame each tick duringSequence.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." SeeMotionTable.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/highFrameare inclusive endpoints intoAnimation.PartFrames.highFrame == -1is the sentinel for "play the entire animation" — the sequencer resolves it toNumFrames - 1atAnimSequenceNode.set_animation_id(AnimSequenceNode.cs:102).- Negative framerate plays the animation in reverse by swapping
lowFrame ↔ highFrameatmultiply_frameratetime; 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:
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)
[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:
- Phase 1: play the
get_link(currentStyle, currentSubstate → DefaultStyle)transition — this unwinds the current style (e.g. "put away sword"). - Phase 2: play the
get_link(DefaultStyle, DefaultStyle's substate → newStyle)transition — this brings up the new style (e.g. "draw greatsword"). - Phase 3: enter the new style's default cycle.
This is the expanded shape of MotionTable.GetObjectSequence (ACE
:76-119):
if ((motion & CommandMask.Style) != 0) {
if (currState.Style == motion) return true; // no-op
// Phase 1: from-link inside old style
if (substate != currState.Substate)
motionData = get_link(currState.Style, currState.Substate, SubstateMod, substate, speedMod);
// Phase 2+3: set-up for new style
if (substate != 0) {
Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);
if (cycles != null) {
if ((cycles.Bitfield & 1) != 0) currState.clear_modifiers();
var link = get_link(currState.Style, substate, SubstateMod, motion, speedMod);
if (link == null && currState.Style != motion) {
link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f);
motionData_ = get_link(DefaultStyle, defaultStyleSub, 1.0f, motion, 1.0f);
}
sequence.clear_physics();
sequence.remove_cyclic_anims();
add_motion(sequence, motionData, speedMod); // unwind link
add_motion(sequence, link, speedMod); // connector link
add_motion(sequence, motionData_, speedMod); // new-style intro
add_motion(sequence, cycles, speedMod); // new cycle
}
}
}
Stance-transition timing. Each link animation has its own duration
computed by GetAnimationLength(animData) = (highFrame - lowFrame) / abs(framerate). Cross-style transitions typically chain 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 fromitoi+1, hooks on frameiwith dirForward|Bothfire. - Backward playback (framerate < 0): as
floor(framePos)decrements fromitoi-1, hooks on frameiwith dirBackward|Bothfire.
The hook list is accumulated on PhysicsObj.add_anim_hook, then drained
after the tick to dispatch side-effects (sound, damage, particle spawn).
This is why WalkBackwards (played as reversed WalkForward) fires
different hooks than WalkForward — the LEFT/RIGHT footstep sounds are
tagged Forward and Backward respectively so they're still semantically
correct when played backward.
5.3 Where attack damage actually lands
MotionTable.GetAttackFrames (DatLoader :87) is the canonical way to
find the "hit frame" of an attack:
foreach (anim in motionData.Anims)
foreach (frame in anim.PartFrames)
foreach (hook in frame.Hooks)
if (hook is AttackHook ah)
frames.Add(totalFrames, ah);
totalFrames++;
// return [(time_percent = frameNum / totalFrames, attackHook), ...]
The server uses this to know when during the playback to roll damage —
the client fires an Attack hook at that frame, the server independently
computes the same timing and waits before applying damage.
AttackCone carries:
- A direction (which arm / weapon hand)
- A height (high/med/low)
- An optional offset (for reach)
So a dual-wield triple-slash produces 6 Attack hooks across the animation, each with its own cone — that's how one animation deals multiple hits.
6 — Blend times and frame epsilons
Retail does NOT use a fixed crossfade duration. Interpolation happens at two levels:
6.1 Within-animation interpolation
Between two adjacent AnimationFrames of the same AnimNode, the sequencer
slerps/lerps based on the fractional part of _framePosition (our port's
AnimationSequencer.cs:619-620). This is the standard "keyframes at
30Hz but render at 60Hz" smoothing. The fractional t is:
t = framePosition - floor(framePosition) // always in [0,1)
6.2 Between-animation transitions
There is no crossfade between the old cycle and the new link
animation. The retail sequencer's advance_to_next_animation
(FUN_00525EB0) just hops:
framePos = nextNode.GetStartFramePosition(); // hard cut
// Apply pos_frame delta to AFrame if PosFrames present
// Zero time spent interpolating between last-frame-of-old-node and
// first-frame-of-new-node
The link animation itself serves as the crossfade — designers built short (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:
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:
- Compute
newFramePos = oldFramePos + framerate*dt - Walk every integer boundary we crossed (could be multiple if dt large)
- For each boundary: add posFrame delta + omega/velocity delta + hooks
- If we overshot the node end, advance to next node with remainder time
This is a time-scaled integration, not framerate-scaled. The actual
render framerate has no effect on animation speed. framerate (in
frames/sec) controls how fast the cursor moves.
7.3 The left→right / forward→backward remap
AC's MotionTable has no cycles for TurnLeft, SideStepLeft, or
WalkBackwards. These are remapped in CMotionInterp::adjust_motion
(ACE MotionInterp.cs:394-428, decompile FUN_00528C20):
switch (motion) {
case RunForward: return; // noop
case WalkBackwards:
motion = WalkForward;
speed *= -0.65f; // BackwardsFactor
break;
case TurnLeft:
motion = TurnRight;
speed *= -1.0f;
break;
case SideStepLeft:
motion = SideStepRight;
speed *= -1.0f;
break;
}
if (motion == SideStepRight)
speed *= 0.5f * (3.12f / 1.25f); // SidestepFactor correction
if (holdKey == HoldKey.Run)
apply_run_to_command(ref motion, ref speed); // §8
The speed sign-flip propagates through multiply_framerate (decompile
FUN_005267E0): any negative framerate swaps startFrame ↔ endFrame
so the advance logic works uniformly:
void multiply_framerate(AnimNode* node, float factor) {
if (factor < 0.0) {
// swap startFrame and endFrame
int tmp = node.startFrame;
node.startFrame = node.endFrame;
node.endFrame = tmp;
}
node.framerate *= factor;
}
7.4 Quaternion interpolation (retail slerp)
The decompiled FUN_005360d0 implements slerp with a special degenerate
fallback:
float dot = dot(q1, q2);
if (dot < 0) { q2 = -q2; dot = -dot; } // shorter arc
if (1-dot <= EPSILON) { // near-parallel
w1 = 1-t; w2 = t; // linear fallback
} else {
omega = acos(dot); sinOmega = sin(omega);
w1 = sin((1-t)*omega)/sinOmega;
w2 = sin(t*omega)/sinOmega;
// Retail-specific: validate results in [0,1]
if (w1 not in [0,1] || w2 not in [0,1]) {
w1 = 1-t; w2 = t; // bail to linear
}
}
result = w1*q1 + w2*q2
Our port has this — no changes needed.
8 — Speed modifiers
8.1 The speed chain
Speed flows through many multipliers before reaching the body:
1. Network: client packs RawMotionState.ForwardSpeed (1.0 for walk, 1.5-2x for run with HoldKey)
2. DoMotion: adjust_motion applies:
- BackwardsFactor (-0.65) if WalkBackwards
- Sign flip if TurnLeft/SideStepLeft
- SidestepFactor (0.5 * 3.12/1.25 = 1.248) if SideStepRight
- If HoldKey.Run: walk→run conversion + run-rate multiplier
3. MotionTable.add_motion: velocity = MotionData.Velocity * speedMod
4. Sequence advance: framerate = AnimData.Framerate * speedMod
5. PhysicsBody.set_local_velocity: clamp to min(velocity, RunAnimSpeed * MyRunRate)
Two constants govern the clamps:
| Constant | ACE value | Retail DAT | Purpose |
|---|---|---|---|
WalkAnimSpeed |
3.12f |
_DAT_007c96e4 |
Meters/sec of a 1.0-speed WalkForward |
RunAnimSpeed |
4.0f |
_DAT_007c96e0 |
Meters/sec of a 1.0-speed RunForward |
SidestepAnimSpeed |
1.25f |
_DAT_007c96e8 |
Meters/sec of a 1.0-speed SideStepRight |
BackwardsFactor |
-0.65f |
_DAT_007c96d8 |
Walk-backwards is 65% as fast as forward |
SidestepFactor |
0.5f |
— | Sidestep defaults to half-speed |
MaxSidestepAnimRate |
3.0f |
_DAT_007c96ec |
Clamp on sidestep speed |
RunTurnFactor |
1.5f |
_DAT_007c96dc |
Hold-run multiplies turn speed by 1.5 |
8.2 Run-rate (stamina-weighted)
Players have a RunRate (0.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.
// 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:
- Input thread sets
RawMotionState.ForwardCommand = WalkForward+HoldKey.Run CMotionInterp::apply_raw_movementcallsadjust_motion→WalkForward + RunbecomesRunForwardwith speed ×runRate- Animation sequencer starts the
(NonCombat, RunForward)cycle - Physics body's local velocity =
RunAnimSpeed * runRate - Client sends
0xF61B MoveToStatepacket with the raw state - Server validates, emits
0xF748 UpdateMotionwith interpreted state to nearby observers - 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:
// 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:
public MotionState MotionState(style/substate/substateMod + modifier list + action list)public enum HoldKey { Invalid, None, Run }+RawState.CurrentHoldKeyfieldCombatStyleGuardsinDoMotion:if (InterpretedState.CurrentStyle != NonCombat) { switch(motion) { case Crouch: return CantCrouchInCombat; case Sitting: return CantSitInCombat; case Sleeping: return CantLieDownInCombat; } if ((motion & ChatEmote) != 0) return CantChatEmoteInCombat; }ActionQueueLimit: tracknumActions; throwTooManyActionsat 6.adjust_motionfull port (currently only in AnimationSequencer.SetCycle — pull it up into MotionInterpreter so DoMotion/StopMotion both use it).apply_run_to_command— handle HoldKey.Run + walk→run conversion + sidestep clamp.SetHoldKey(HoldKey k, bool cancelMoveTo)+set_hold_run(int, bool).motion_allows_jump(uint substate)— the ranged-check helper:// Reload..Pickup, TripleThrust..MagicPowerUp10Purple, MagicPowerUp01..10, // Crouch..Sleeping, AimLevel..MagicPray, Falling → YouCantJumpFromThisPositionmove_to_interpreted_state(InterpretedMotionState state)— receive and replay the server's authoritative state, including action-stamp comparison logic fromFUN_005295D0.enter_default_state()andHandleEnterWorld()/HandleExitWorld().
AnimationSequencer.cs additions:
EnqueueStyleTransition(uint oldStyle, uint oldSub, uint newStyle, float speed)— the multi-link chain from §4.AddModifier(uint modCommand, float speed)/RemoveModifier— maintain the overlay list. When a modifier is active, itsMotionData.VelocityandOmegaare added to the base cycle's (not replaced). SeeCombinePhysics/subtract_physicsin ACE Sequence.cs.AddAction(uint actionCommand, float speed)— queue a one-shot link+cycle pair. The action plays to completion then pops.- Per-frame hook dispatch — populate a
PendingHooks: List<AnimationHook>each tick. Callers consume it via a newIReadOnlyList<AnimationHook> ConsumePendingHooks()method. PosFramesintegration — if the current Animation has PosFrames, apply the per-frame origin+orientation delta to a running AFrame (exposed as aPartTransformon "part -1" or a separateRootMotionproperty).- 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)— seeMotionTable.change_cycle_speed. CurrentlySetCycle'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'sMotionTable+MotionTableManager.AttackHook/SoundHook/SoundTableHooktypes (already in DatReaderWriter; just need our own switch dispatch).
Wire layer (R10 adjacent but referenced here):
MovementDatareader/writer matching ACE's layout byte-for-byte.InterpretedMotionState+RawMotionState+MotionItemcodecs — holtburger has working Rust implementations to mirror.- Server→client
0xF748 Motionhandler that constructsMovementStruct { type=2, motion=interp.ForwardCommand, ... }and callsMotionInterpreter.PerformMovement.
14 — Conformance test golden cases
-
Cycle key for (NonCombat, WalkForward) =
0x003D0005Assert.Equal(0x003D0005, MotionTable.MakeCycleKey(MotionStance.NonCombat, MotionCommand.WalkForward)); -
Cycle key for (Magic, MeditateState) =
0x0049011CAssert.Equal(0x0049011C, MotionTable.MakeCycleKey(MotionStance.Magic, MotionCommand.MeditateState)); -
TurnLeft remaps to TurnRight with speed ×-1
uint motion = (uint)MotionCommand.TurnLeft; float speed = 1.0f; interp.AdjustMotion(ref motion, ref speed, HoldKey.None); Assert.Equal((uint)MotionCommand.TurnRight, motion); Assert.Equal(-1.0f, speed); -
WalkBackwards remaps to WalkForward ×-0.65
uint motion = (uint)MotionCommand.WalkBackwards; float speed = 1.0f; interp.AdjustMotion(ref motion, ref speed, HoldKey.None); Assert.Equal((uint)MotionCommand.WalkForward, motion); Assert.Equal(-0.65f, speed); -
Hold-Run + WalkForward becomes RunForward × runRate
uint motion = (uint)MotionCommand.WalkForward; float speed = 1.0f; interp.MyRunRate = 1.5f; interp.AdjustMotion(ref motion, ref speed, HoldKey.Run); Assert.Equal((uint)MotionCommand.RunForward, motion); Assert.Equal(1.5f, speed, 1e-6); -
SideStepRight applies SidestepFactor
uint motion = (uint)MotionCommand.SideStepRight; float speed = 1.0f; interp.AdjustMotion(ref motion, ref speed, HoldKey.None); Assert.Equal(1.248f, speed, 1e-3); // 0.5 * 3.12 / 1.25 -
Seven actions queued → TooManyActions on 7th
var interp = MakeInterp(); for (int i = 0; i < 6; i++) Assert.Equal(WeenieError.None, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters())); Assert.Equal(WeenieError.TooManyActions, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters())); -
Crouch in SwordCombat fails
interp.InterpretedState.CurrentStyle = (uint)MotionStance.SwordCombat; Assert.Equal(WeenieError.CantCrouchInCombat, interp.DoMotion(MotionCommand.Crouch, new MovementParameters())); -
Jump while airborne fails
phys.TransientState &= ~TransientStateFlags.OnWalkable; Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, interp.Jump(1.0f, 0)); -
Wave emote in NonCombat succeeds
interp.InterpretedState.CurrentStyle = (uint)MotionStance.NonCombat; Assert.Equal(WeenieError.None, interp.DoMotion(MotionCommand.Wave, new MovementParameters())); -
AnimationSequencer.SetCycle with speed=0 doesn't advance
seq.SetCycle(NonCombat, RunForward, speedMod: 0f); var pre = seq.Advance(0.016f); var post = seq.Advance(0.016f); AssertAllTransformsEqual(pre, post); -
Negative framerate reverses playback (golden from decompiled multiply_framerate):
var node = new AnimNode(anim, framerate: -30f, startFrame: 0, endFrame: 10, isLooping: true); // After multiply_framerate, cursor should start near endFrame+1 - ε Assert.True(node.GetStartFramePosition() > 10.0 - 1e-3); -
MotionData.Velocity scales linearly with speedMod
var md = new MotionData { Velocity = new Vector3(0, 4, 0) }; // 4 m/s forward seq.AddMotion(md, speedMod: 0.5f); Assert.Equal(new Vector3(0, 2, 0), seq.Velocity); -
Attack hook fires exactly once per animation cycle
seq.SetCycle(SwordCombat, SlashHigh, speedMod: 1f); var hitCount = 0; for (int i = 0; i < 100; i++) { seq.Advance(0.016f); hitCount += seq.ConsumePendingHooks().Count(h => h is AttackHook); } Assert.Equal(1, hitCount);
15 — Cross-cut notes
-
Thread-safety:
MotionTableis read-only after unpack; walking it is safe from any thread.SequenceandMotionInterpreterare 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:
CMotionInterpstruct offsets are cited throughout (+0x20ForwardCommand,+0x4Cinterp-ForwardCommand,+0x70StandingLongJump,+0x74JumpExtent,+0x78ActionStamp,+0x7CMyRunRate). 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
DoInterpretedMotionsuccess adds aMotionNode { contextID, motion, jumpErrorCode }to the PendingMotions linked list. This list drains when the sequencer reports the animation has fully played (viaHookObj.add_anim_hook(AnimationDone)). Until then,physicsObj->IsAnimating = trueandmotions_pending() == true. -
Link-removal optimization:
MotionTableManager.remove_redundant_linksscans 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 callingsequence.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.csL148–L193 (error codes).