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>
301 lines
17 KiB
Markdown
301 lines
17 KiB
Markdown
# 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.
|