acdream/docs/research/2026-06-26-movement-animation-retail-parity-audit.md
Erik 33ad231d18 docs(L.2b/L.1b): movement wire-parity slice design + 2026-06-26 audits
Imports the two 2026-06-26 movement/animation retail-parity research docs
(audit D1-D12 + ACE-vs-2013 command-catalog gap) and adds the design spec
for the first implementation slice: dual command catalog (AceModern runtime
default + full-extraction Retail2013 conformance) + outbound wire parity
(RawMotionState default-difference packing D1, contact|standingLongjump
trailing byte D3, retail JumpPack layout D4). Decomp-verbatim, tests-first;
D6 motion-interpreter input-state construction explicitly deferred.

Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md
Phases: L.2b (movement wire/contact authority) + L.1b (command router).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:34:33 +02:00

17 KiB

Movement and Animation Retail Parity Audit

Date: 2026-06-26

Verdict on approach

The approach is good if it stays decomp-first and test-first. For this area, "verbatim" needs to mean:

  • Outbound wire bytes match retail packers for the same input state.
  • Incoming movement packets are parsed into the same semantic state retail would unpack.
  • Local, remote player, monster, NPC, and force-walk paths flow through retail-equivalent motion state transitions before animation selection.
  • Any behavior that cannot be proven from retail decomp, Ghidra, or retail captures is marked unresolved instead of tuned by feel.

The approach is not good if it begins as a broad rewrite or animation polish pass. The current code has several comments and tests that already encode approximations. Those need to become failing parity tests first, then implementation slices.

Sources checked

Primary oracle:

  • docs/research/named-retail/acclient_2013_pseudo_c.txt
  • docs/research/named-retail/acclient.h
  • docs/research/named-retail/acclient.c

Secondary oracle:

  • docs/research/acclient_decompiled.c
  • Ghidra HTTP bridge verified on http://127.0.0.1:8081; methods?limit=3 responded and decompile-by-address worked during the audit. Codex tool discovery did not expose a first-class Ghidra MCP namespace in this thread, so direct HTTP is the usable path here.

Current implementation surfaces:

  • src/AcDream.Core.Net/Messages/MoveToState.cs
  • src/AcDream.Core.Net/Messages/AutonomousPosition.cs
  • src/AcDream.Core.Net/Messages/JumpAction.cs
  • src/AcDream.Core.Net/Messages/UpdateMotion.cs
  • src/AcDream.Core.Net/Messages/UpdatePosition.cs
  • src/AcDream.Core.Net/Messages/CreateObject.cs
  • src/AcDream.Core.Net/WorldSession.cs
  • src/AcDream.Core/Physics/MotionInterpreter.cs
  • src/AcDream.Core/Physics/AnimationSequencer.cs
  • src/AcDream.Core/Physics/ServerControlledLocomotion.cs
  • src/AcDream.Core/Physics/RemoteMoveToDriver.cs
  • src/AcDream.App/Input/PlayerMovementController.cs
  • src/AcDream.App/Rendering/GameWindow.cs

Retail source map

Outbound movement to ACE

  • CommandInterpreter::ShouldSendPositionEvent at 0x006b45e0, pseudo line around 700233.
  • CommandInterpreter::SendMovementEvent at 0x006b4680, pseudo line around 700274.
  • CommandInterpreter::SendPositionEvent at 0x006b4770, pseudo line around 700316.
  • CM_Movement::Event_AutonomousPosition at 0x006af790, sub-opcode 0xf753.
  • CM_Movement::Event_Jump at 0x006afa70, sub-opcode 0xf61b.
  • CM_Movement::Event_MoveToState at 0x006afc00, sub-opcode 0xf61c.
  • MoveToStatePack::Pack at 0x005168f0, pseudo line around 284694.
  • AutonomousPositionPack::Pack at 0x00516af0, pseudo line around 284795.
  • JumpPack::Pack at 0x00516d10, pseudo line around 284915.
  • RawMotionState::Pack at 0x0051ed10, pseudo line around 293761.

Retail RawMotionState defaults:

  • current_holdkey = HoldKey.None
  • current_style = 0x8000003d
  • forward_command = 0x41000003
  • forward_holdkey = HoldKey.Invalid
  • forward_speed = 1.0
  • sidestep_command = 0
  • sidestep_holdkey = HoldKey.Invalid
  • sidestep_speed = 1.0
  • turn_command = 0
  • turn_holdkey = HoldKey.Invalid
  • turn_speed = 1.0

Retail RawMotionState::Pack compares against these defaults. It does not set a field bit merely because the caller supplied a value. For example, forward_speed == 1.0f is omitted.

Incoming movement and force movement

  • MovementManager::PerformMovement at 0x005240d0, pseudo line around 300194, routes movement types 1-5 to CMotionInterp and 6-9 to MoveToManager.
  • MovementManager::unpack_movement at 0x00524440, pseudo line around 300563, unpacks interpreted motion plus MoveToObject, MoveToPosition, TurnToObject, and TurnToHeading.
  • MovementParameters::UnPackNet at 0x0052ac70, pseudo line around 308118.
  • MovementParameters::get_command at 0x0052aa00, pseudo line around 307946.
  • MoveToManager::_DoMotion, MoveToPosition, and related queue logic are around pseudo lines 306351, 307187, and 307521.

Retail movement types from acclient.h:

  • 0: interpreted motion in inbound packet context
  • 1: raw motion
  • 2: interpreted motion
  • 3: stop raw motion
  • 4: stop interpreted motion
  • 5: stop completely
  • 6: move to object
  • 7: move to position
  • 8: turn to object
  • 9: turn to heading

Animation and motion interpretation

  • CMotionInterp::apply_run_to_command at 0x00527be0, pseudo line around 305062.
  • CMotionInterp::get_state_velocity at 0x00527d50.
  • CMotionInterp::adjust_motion at 0x00528010, pseudo line around 305343.
  • CMotionInterp::apply_raw_movement at 0x005287e0.
  • CMotionInterp::DoInterpretedMotion around pseudo line 305575.
  • CMotionInterp::apply_current_movement around pseudo line 305713.
  • CMotionTable::GetObjectSequence around pseudo line 298636.
  • CSequence append/advance/update logic around pseudo lines 301622, 301777, 301839, and 302425.

Key retail normalization:

  • SideStepLeft becomes SideStepRight with negative speed.
  • SideStepRight speed becomes (3.11999989 / 1.25) * 0.5 * speed.
  • TurnLeft becomes TurnRight with negative speed.
  • WalkBackward becomes WalkForward with -0.649999976 * speed.
  • Hold-key Run upgrades forward walk to run, multiplies turn speed by 1.5, and multiplies sidestep by run rate with an absolute clamp of 3.0.

Confirmed divergences

D1: MoveToState raw flags are not retail

Current MoveToState.Build sets raw-motion flags from nullable/presence inputs. Retail sets flags by comparing the complete RawMotionState against default values. This means the current client can over-send:

  • CurrentHoldKey = None
  • ForwardSpeed = 1.0
  • SidestepSpeed = 1.0
  • TurnSpeed = 1.0
  • likely per-axis hold keys when they are default Invalid

Existing MoveToStateTests.Build_WalkForward_IncludesForwardCommandInFlags currently asserts the non-retail behavior by expecting a ForwardSpeed flag for speed 1.0.

Impact: outbound movement bytes can differ from retail for normal running, walking, sidestepping, and turning.

D2: RawMotionState action-list and style packing are incomplete

Retail bitfield layout uses 11 one-bit flags plus num_actions : 5 at bits 11-15. Current comments imply the command list length is broader than retail. Current builder does not cover full action-list packing or current-style scenarios.

Impact: action/modifier movement states cannot be proven retail-equivalent.

D3: MoveToState longjump bit is not modeled

Retail MoveToStatePack::Pack writes one trailing byte:

  • bit 0x01: contact
  • bit 0x02: standing_longjump

Current GameWindow passes only contact 0/1 into a generic byte. The source of standing_longjump is not wired as a named state.

Impact: standing longjump movement-state updates can differ from retail.

D4: JumpAction packet layout is retail-incompatible

Retail JumpPack packs:

  • float extent
  • Vector3 velocity
  • full Position
  • four u16 update timestamps
  • alignment

Current JumpAction.Build packs extent and velocity, then timestamps, then extra objectGuid and spellId, and does not pack Position.

Impact: local jump movement update is a high-confidence wire mismatch.

D5: Position heartbeat is close but not fully proven

Retail ShouldSendPositionEvent gates autonomous position updates by active state, autonomy level, player smartbox, 1.0 second interval, cell/frame change, and contact-plane change. Current PlayerMovementController has a similar cadence.

Open proof point: retail SendMovementEvent visibly stamps last_sent_position_time; retail SendPositionEvent stamps time, position, and contact plane. Current GameWindow calls NotePositionSent after both MTS and AP, stamping all three.

Impact: the client may suppress or reorder autonomous position sends differently after movement-state sends.

D6: MotionInterpreter lacks canonical retail raw-to-interpreted normalization

Retail CMotionInterp::apply_raw_movement copies raw state, calls adjust_motion for forward/sidestep/turn, applies run-hold behavior, then applies interpreted movement. Current MotionInterpreter.DoMotion stores commands directly and applies velocity directly.

Specific visible risks:

  • SideStepLeft may produce no velocity because velocity code only recognizes SideStepRight.
  • backward and sidestep speeds are patched elsewhere instead of normalized at the retail source point.
  • turn run-speed scaling is not faithfully represented.
  • local jump lateral velocity relies on comments that say this is temporary until adjust_motion is ported.

Impact: running, slow walking, slow side walking, backward movement, turning, and jump lateral motion are not retail-proven.

D7: Animation application is split away from retail motion flow

Retail movement application sequences style, forward or falling, sidestep start/stop, turn start/stop, and actions through CMotionInterp and CMotionTable::GetObjectSequence. Current AnimationSequencer implements useful pieces, but MotionInterpreter.apply_current_movement is mostly velocity-oriented and does not drive animation state through the same retail order.

Impact: animations can look plausible while being state-machine divergent.

D8: Force-walk and MoveTo are approximations

Retail MoveToManager is a queued command system over CMotionInterp, with pre-turn, move, aux turn, final heading, sticky targeting, progress failure, and MovementParameters::get_command walk/run/hold-key selection.

Current ServerControlledLocomotion, RemoteMoveToDriver, and PlayerMovementController.BeginServerAutoWalk approximate visible steering and cycle selection.

Impact: ACE force walking and monster/NPC MoveTo behavior are not retail-equivalent.

D9: Inbound movement types 8 and 9 are dropped

Retail inbound movement supports:

  • 8: TurnToObject
  • 9: TurnToHeading

Current UpdateMotion handles interpreted state and MoveTo types 6/7, but not 8/9.

Impact: incoming server turn commands for characters, monsters, and NPCs are ignored.

D10: Spawn-time movement state is weaker than live movement state

CreateObject can detect movement types and some flags, but it does not preserve full MoveTo target/origin/threshold data the way live UpdateMotion does.

Impact: monsters/NPCs spawned while already moving cannot be driven retail-equivalently until a later movement packet arrives.

D11: Sequence/autonomy data is parsed then discarded

Retail carries movement sequence, server-control sequence, autonomous state, motion flags, and position sequence into movement application. Current events expose only a subset.

Impact: ordering, stale update rejection, and force-control transitions cannot match retail.

D12: Jump/falling/contact gates are simplified

Retail allows specific movement while falling/dead and has separate jump checks for posture, stamina, constraints, pending motion, contact, and leave/hit-ground reapplication. Current code blocks or simplifies several of these paths.

Impact: airborne movement, falling animation, dead/fallen movement commands, and jump eligibility are not retail-proven.

Priority plan

Phase 0: Lock the oracle

  1. Create small retail-reference helpers/tests that encode RawMotionState::Pack, MoveToStatePack::Pack, AutonomousPositionPack::Pack, JumpPack::Pack, MovementParameters::UnPackNet, and MovementParameters::get_command.
  2. Every helper must cite the decomp function and address in the test name or comments.
  3. Any unclear branch gets a TODO with address and blocked status, not an approximation.

Exit criteria: failing tests describe current divergences without changing runtime behavior yet.

Phase 1: Fix outbound wire parity

  1. Change MoveToState packing from presence-based to retail default-difference packing.
  2. Add explicit raw-state model with defaults, current style, action list count, and per-axis hold keys.
  3. Add explicit contact and standingLongjump parameters for MTS trailing byte.
  4. Replace JumpAction with retail JumpPack: extent, velocity, full position, four timestamps, align; remove extra object/spell fields unless another retail opcode proves they belong elsewhere.
  5. Verify timestamp order: instance [8], server-control [5], teleport [4], force-position [6].
  6. Audit whether MTS should stamp only last-send time or also last-send position/contact plane.

Exit criteria: golden byte tests pass for walk, run, sidestep, turn, slow walk/toggle-run, jump, contact, longjump, and AP heartbeat cases.

Phase 2: Port retail raw/interpreted motion core

  1. Introduce retail-shaped RawMotionState and InterpretedMotionState in core physics.
  2. Port adjust_motion, apply_run_to_command, get_state_velocity, and apply_raw_movement.
  3. Route local player input through this path instead of compensating in PlayerMovementController.
  4. Preserve run rate and hold-key semantics, including toggle-run slow-walk behavior.

Exit criteria: movement state tests prove backward, sidestep left/right, run turn, run sidestep clamp, forward run, and slow-walk semantics match retail math.

Phase 3: Reconnect animation to retail movement application

  1. Port enough of CMotionInterp::apply_current_movement to sequence style, forward/falling, sidestep stop/start, turn stop/start, and actions in retail order.
  2. Keep existing AnimationSequencer where it already matches retail, but move normalization out of sequencer-only code and into motion interpretation.
  3. Add parity tests for reverse playback, link fallback, modifier state, action sequencing, and root-motion composition using real retail motion table data.

Exit criteria: local and remote animation selection flows from the same interpreted state that drives velocity.

Phase 4: Port inbound movement and force-walk behavior

  1. Extend UpdateMotion to parse and surface types 8 and 9.
  2. Preserve sequence/autonomy/motion flags through WorldSession events.
  3. Preserve full spawn-time MoveTo state in CreateObject.
  4. Replace approximate server-driven locomotion with a retail-shaped MoveToManager queue over CMotionInterp.
  5. Port MovementParameters::get_command, including walk/run threshold, can_run, force-walk, hold-key application, fail distance, sticky/target following, and final heading.

Exit criteria: incoming interpreted motion, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading, and ACE force-walk packets animate and move through retail-equivalent state transitions.

Phase 5: Integration captures and regression guard

  1. Capture retail-equivalent fixtures for normal run, slow walk, sidestep, slow sidestep, backward, turn, jump, forced walk, NPC MoveTo, monster chase/flee, and remote player interpolation.
  2. Assert wire bytes for outbound messages.
  3. Assert parsed semantic state for inbound messages.
  4. Assert animation command sequence and velocity for local and remote entities.
  5. Run full test suite plus a targeted in-app smoke pass.

Exit criteria: no movement or animation path is accepted without a retail-addressed test.

Initial test inventory to add or replace

  • MoveToState golden tests: default raw state, walk forward speed 1.0, run forward, backward, sidestep right/left, turn right/left, current style, action count, contact/longjump byte.
  • JumpAction golden tests: retail JumpPack layout with full position and no object/spell fields.
  • AutonomousPosition tests: timestamp order and contact byte.
  • PlayerMovementController tests: forward walk/run, slow walk/toggle-run, backward, sidestep, turn-only run, jump release, server auto-walk suppression.
  • ShouldSendPositionEvent tests: pre-interval cell change, pre-interval contact-plane change, interval frame change, idle no-send, airborne AP suppression.
  • UpdateMotion tests: interpreted, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading.
  • CreateObject tests: spawn-time MoveTo preservation.
  • WorldSession tests: sequence/autonomy propagation.
  • MotionInterpreter tests: adjust_motion, apply_run_to_command, get_state_velocity, contact gates, falling fallback, jump eligibility.
  • AnimationSequencer tests: retail sequence order, stop/start transitions, reverse playback, link fallback, modifier combine/subtract behavior.
  • MoveToManager tests: pre-turn, move, aux turn, final heading, target following, fail distance, force-walk walk/run choice.

Implementation rule

No code path in this system should remain justified by "feels like retail", "ACE-compatible", or "close enough". If the decomp is unclear, use Ghidra or captures to resolve it. If it still cannot be resolved, leave the behavior behind a clearly named unresolved test or blocked TODO rather than guessing.