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>
This commit is contained in:
Erik 2026-06-30 21:34:33 +02:00
parent ca94b479bf
commit 33ad231d18
3 changed files with 736 additions and 0 deletions

View file

@ -0,0 +1,151 @@
# ACE vs 2013 Retail Motion Command Gap
Date: 2026-06-26
## Why this matters
The movement/animation parity work cannot use one command catalog blindly.
The 2013 `acclient.exe` decomp has a `command_ids[0x198]` table and a matching command-name table, but ACE's `MotionCommand` enum is based on a later client catalog. The local DAT motion tables also contain later-client command keys. If AcDream reconstructs incoming ACE wire commands through the 2013 table only, some server-triggered animations will disappear.
The concrete example is lifestone recall:
- 2013 retail decomp names `LifestoneRecall` as `0x10000150`.
- Current ACE names `LifestoneRecall` as `0x10000153`.
- Local DAT `MotionTable` links include both values, but many later recall/offhand commands only exist under the ACE-shifted value.
## Sources checked
- 2013 retail decomp:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `command_ids[0x198]` at `0x007c73e8`
- command-name table around `0x008041ec..0x0080444c`
- ACE current master:
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/MotionCommand.cs`
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/CommandMasks.cs`
- Local DATs:
- `C:\Users\erikn\Documents\Asheron's Call\client_portal.dat`
- scanned 436 `MotionTable` records with `Chorizite.DatReaderWriter 2.1.7`
- Current AcDream resolver:
- `src/AcDream.Core/Physics/MotionCommandResolver.cs`
## Catalog mismatch
Parsed inventory:
- 2013 retail command names parsed: `406`
- ACE command names parsed: `409`
- Common names: `400`
- Common names with different values: `130`
The important mismatch is a contiguous low-word `+3` shift beginning after the targeting UI block:
| Name | 2013 retail | ACE current |
|---|---:|---:|
| `SnowAngelState` | `0x43000115` | `0x43000118` |
| `MeditateState` | `0x43000119` | `0x4300011C` |
| `Pickup5` | `0x40000133` | `0x40000136` |
| `HouseRecall` | `0x10000137` | `0x1000013A` |
| `SitState` | `0x4300013A` | `0x4300013D` |
| `HaveASeat` | `0x1300014F` | `0x13000152` |
| `LifestoneRecall` | `0x10000150` | `0x10000153` |
| `MarketplaceRecall` | `0x10000163` | `0x10000166` |
| `AllegianceHometownRecall` | `0x1000016E` | `0x10000171` |
| `PKArenaRecall` | `0x1000016F` | `0x10000172` |
| `OffhandSlashHigh` | `0x10000170` | `0x10000173` |
ACE's enum comments show the branch point:
- `SkillHealSelf = 0x1000010e`
- `SkillHealOther = 0x1000010f`
- duplicate/commented legacy slots around `0x010f..0x0111`
- `SnowAngelState = 0x43000118`
2013 retail instead has:
- `SkillHealSelf = 0x0900010E`
- `NextMonster = 0x0900010F`
- `PreviousMonster = 0x09000110`
- `ClosestMonster = 0x09000111`
- `NextPlayer = 0x09000112`
- `PreviousPlayer = 0x09000113`
- `ClosestPlayer = 0x09000114`
- `SnowAngelState = 0x43000115`
So this is a later-client catalog divergence, not just a single bad enum value.
## MotionTable availability
I scanned all 436 local DAT `MotionTable` records for the selected 2013 and ACE values. Counts below are link target hits; recall/action commands are stored in `Links`, not `Cycles`.
| Command | 2013 value hits | ACE value hits | Interpretation |
|---|---:|---:|---|
| `HouseRecall` | `317` | `24` | both exist; the old value is very common |
| `LifestoneRecall` | `28` | `19` | both exist; ACE `/ls` value can animate |
| `MarketplaceRecall` | `0` | `19` | only ACE-shifted value exists |
| `AllegianceHometownRecall` | `0` | `19` | only ACE-shifted value exists |
| `PKArenaRecall` | `0` | `18` | only ACE-shifted value exists |
| `OffhandSlashHigh` | `0` | `31` | only ACE-shifted value exists |
This means the local DATs are not pure 2013-command-table data. They include later-client action keys that match ACE/DatReaderWriter.
## Current AcDream resolver behavior
Current `MotionCommandResolver.ReconstructFullCommand` results:
| Wire low | Current full command |
|---:|---:|
| `0x0137` | `0x40000137` |
| `0x013A` | `0x1000013A` |
| `0x0150` | `0x13000150` |
| `0x0153` | `0x10000153` |
| `0x0163` | `0x09000163` |
| `0x0166` | `0x10000166` |
| `0x016E` | `0x1000016E` |
| `0x016F` | `0x1000016F` |
| `0x0170` | `0x10000170` |
| `0x0171` | `0x10000171` |
| `0x0172` | `0x10000172` |
| `0x0173` | `0x10000173` |
Implications:
- ACE `/ls` wire `0x0153` currently reconstructs to `0x10000153`, which is good for ACE and local DATs.
- 2013 retail lifestone wire `0x0150` would currently reconstruct as `ScanHorizon` chat emote, not `LifestoneRecall`.
- The override comment in `MotionCommandResolver` is wrong: the mismatch does not start at `AllegianceHometownRecall`; it starts earlier around `SnowAngelState`.
- The override range `0x016E..0x0197` maps `0x016E/0x016F/0x0170` to action-class commands even though ACE names those low words as UI commands. This probably does not hurt normal ACE animation broadcasts, but it is not a clean catalog model.
## Recommendation
Do not replace ACE/DatReaderWriter reconstruction with 2013 `command_ids` globally.
Use two catalogs explicitly:
1. `Retail2013CommandCatalog`
- For proving decomp behavior and reproducing the 2013 client command table.
- Useful for old-client conformance tests.
2. `AceModernCommandCatalog`
- For reconstructing ACE `InterpretedMotionState` wire `u16` commands into the full 32-bit motion commands that match local DAT motion tables.
- Should be the runtime default while talking to ACE.
Then add a resolver mode/test matrix:
- ACE mode:
- `0x0153 -> 0x10000153` (`LifestoneRecall`)
- `0x0166 -> 0x10000166` (`MarketplaceRecall`)
- `0x0171 -> 0x10000171` (`AllegianceHometownRecall`)
- `0x0173 -> 0x10000173` (`OffhandSlashHigh`)
- 2013 retail mode:
- `0x0150 -> 0x10000150` (`LifestoneRecall`)
- `0x0163 -> 0x10000163` (`MarketplaceRecall`)
- `0x016E -> 0x1000016E` (`AllegianceHometownRecall`)
- `0x0170 -> 0x10000170` (`OffhandSlashHigh`)
Animation lookup should be tested against the DAT motion tables too, not just enum names. A command is visually usable only if the entity's `MotionTable` has a `Links` or `Modifiers` entry for that full command.
## Bottom line
For ACE interop, the lifestone recall animation is not missing from the local DAT motion tables. It is missing only if we force ACE's wire `0x0153` through the 2013 retail command catalog.
The gap is real and broader than lifestone recall: the later-client/ACE command catalog diverges from the 2013 decomp for 130 common names, and several important animations only exist under the ACE-shifted IDs in the local DATs.

View file

@ -0,0 +1,301 @@
# 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.