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.

View file

@ -0,0 +1,284 @@
# Movement / Animation Wire-Parity Slice — Design
Date: 2026-06-30
Phase: **L.2b** (movement wire/contact authority) + **L.1b** (command router + motion-state cleanup)
Standard: **decomp-verbatim, tests-first. No approximations, no "feels like retail."**
## Source documents (read first)
- `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the 12
confirmed divergences (D1D12) and the Phase 05 plan.
- `docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md` — the ACE-vs-2013
command-catalog divergence (130 common names with different values; the `+3`
low-word shift beginning at `SnowAngelState`).
- Oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build) + `docs/research/named-retail/acclient.h`. Live confirmation via the
Ghidra HTTP bridge at `http://127.0.0.1:8081` (decompile-by-address).
- Plans of record this slice serves: `docs/plans/animation-system-audit.md` (L.1)
and `docs/plans/2026-04-29-movement-collision-conformance.md` (L.2).
## Goal
Make the **outbound movement wire bytes** (`MoveToState 0xF61C`, `Jump 0xF61B`,
`AutonomousPosition 0xF753`) and the **inbound→animation command catalog**
byte-for-byte / value-for-value faithful to retail, proven by tests whose golden
values are derived directly from the 2013 decomp packers and cross-checked
against holtburger.
This slice fixes the *packing* and the *catalog*. It does **not** port the
motion-interpreter input-state construction (`adjust_motion` /
`apply_raw_movement`) — that is the next slice (D6 / audit Phase 2), explicitly
deferred below.
## Scope
### In scope
- **D1**`RawMotionState::Pack` default-difference flag packing (replaces
presence-based flags).
- **D3**`MoveToStatePack` trailing byte = `contact | standingLongjump`.
- **D4**`JumpPack` layout (extent, velocity, full Position, four timestamps;
drop the spurious objectGuid/spellId).
- **Catalog gap** — dual command catalog (`AceModernCommandCatalog` runtime
default + `Retail2013CommandCatalog` conformance), built behind an
`IMotionCommandCatalog` seam; full 2013 `command_ids[0x198]` extraction;
DAT-availability tests.
- **Phase 0 oracle tests** — written first and failing, encoding the four retail
`Pack` functions and the catalog matrices.
### Out of scope (later L.1/L.2 slices — see "Deferred / unresolved")
D2 (runtime actions-list/current_style emission), D5 (heartbeat-clock stamping —
*audit only* this slice), D6 (`adjust_motion`/`apply_raw_movement` input-state),
D7 (animation/motion application reorder), D8 (MoveToManager/force-walk), D9
(inbound types 8/9), D10 (spawn-time MoveTo), D11 (sequence/autonomy
propagation), D12 (jump/contact gates).
## Decisions (locked with the user 2026-06-30)
1. **First slice = catalog + outbound wire** (not catalog-only, not + motion core).
2. **Golden-byte oracle = decomp-derived + holtburger cross-check**, confirmed via
the Ghidra bridge. No live retail capture for this slice (captures are reserved
for Phase 2, where retail's *input state* is the genuine unknown).
3. **Catalog mechanism = Option A**`IMotionCommandCatalog` interface; static
`MotionCommandResolver.ReconstructFullCommand(ushort)` is retained, delegating
to an `AceModernCommandCatalog` singleton so all ~10 runtime call sites are
unchanged. Tests instantiate `Retail2013CommandCatalog` explicitly.
4. **2013 table depth = full extraction** (~408 `command_ids[0x198]` entries with
provenance), not a test-matrix stub.
## Component design
### 1. Command catalog (L.1b)
**Seam.** New interface in `AcDream.Core.Physics`:
```
public interface IMotionCommandCatalog
{
/// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value.
/// Returns 0 if no entry matches.
uint ReconstructFullCommand(ushort wireCommand);
}
```
**`AceModernCommandCatalog : IMotionCommandCatalog`** — the runtime default.
Built from the DatReaderWriter / ACE `MotionCommand` enum (these share values;
ACE's enum is the authoritative ACE-modern catalog and matches the local DATs).
Reconstruction = wire-low → full lookup, with collisions resolved by the
documented retail class priority (lower class byte wins: Action 0x10 < SubState
0x41 < Style 0x80 …) applied **at build time**, producing a flat
`IReadOnlyDictionary<ushort,uint>`.
- **Verify and (likely) delete the magic override.** The current
`MotionCommandResolver.ApplyNamedRetailOverrides` force-maps `0x016E0x0197`
to `0x10000000 | lo`. Once `AceModernCommandCatalog` is built cleanly from the
ACE enum, assert the ACE test matrix passes (`0x0153→0x10000153`,
`0x0166→0x10000166`, `0x0171→0x10000171`, `0x0173→0x10000173`). If it passes
**without** the override loop, delete the loop (unexplained magic range; see
the magic-number-audit memory). If a specific command still resolves to the
wrong class, add a **per-command, cited** override — never a blind range.
- **Fix the wrong comment.** The shift starts at `SnowAngelState` (`0x43000115`
`0x43000118`), not `AllegianceHometownRecall`.
**`Retail2013CommandCatalog : IMotionCommandCatalog`** — conformance/reference.
Built from the **full** 2013 `command_ids[0x198]` table at `0x007c73e8` paired
with the command-name table at `0x008041ec..0x0080444c`. Deterministic
extraction from `acclient_2013_pseudo_c.txt` (and/or the Ghidra bridge), emitted
as a generated/static name→value table in the source with a provenance header
citing both addresses. ~408 entries.
**`MotionCommandResolver`** keeps `public static uint ReconstructFullCommand(ushort)`
delegating to a private `static readonly AceModernCommandCatalog s_aceModern`.
All current callers (`AnimationCommandRouter`, `CombatAnimationPlanner`, 8×
`GameWindow`) are untouched.
**DAT-availability tests.** A command animates only if the entity's `MotionTable`
has a `Links` or `Modifiers` entry for the full 32-bit value. Tests
(`MotionCommandCatalogTests`, real-DAT-backed via `Chorizite.DatReaderWriter`):
- ACE mode: `0x0153→0x10000153 LifestoneRecall`, `0x0166→0x10000166`,
`0x0171→0x10000171`, `0x0173→0x10000173`.
- 2013 mode: `0x0150→0x10000150 LifestoneRecall`, `0x0163→0x10000163`,
`0x016E→0x1000016E`, `0x0170→0x10000170`.
- Availability: `LifestoneRecall (ACE 0x10000153)` and `HouseRecall` exist in
local DAT MotionTables; `MarketplaceRecall / AllegianceHometownRecall /
PKArenaRecall / OffhandSlashHigh` exist **only** under the ACE-shifted IDs
(reproduces the gap doc's link-hit scan as a fixture assertion).
### 2. RawMotionState model + default-difference packing (D1)
**Data type** `AcDream.Core.Physics.RawMotionState` — a value type mirroring the
retail struct (`acclient.h RawMotionState::PackBitfield`, decomp `0x0051ed10`):
| field | retail default | bit |
|---|---|---:|
| current_holdkey | `HoldKey.None` (1) | 0x001 |
| current_style | `0x8000003D` | 0x002 |
| forward_command | `0x41000003` | 0x004 |
| forward_holdkey | `HoldKey.Invalid` (0) | 0x008 |
| forward_speed | `1.0f` | 0x010 |
| sidestep_command | `0` | 0x020 |
| sidestep_holdkey | `HoldKey.Invalid` (0) | 0x040 |
| sidestep_speed | `1.0f` | 0x080 |
| turn_command | `0` | 0x100 |
| turn_holdkey | `HoldKey.Invalid` (0) | 0x200 |
| turn_speed | `1.0f` | 0x400 |
| actions (list) | empty | `num_actions<<11` (bits 1115) |
Pure data, no GL/Net dependency. Lives in Core.Physics so the Phase-2 motion
interpreter (also Core.Physics) can populate it without a Core.Net dependency.
A `RawMotionState.Default` exposes the retail defaults.
**Packer** in `AcDream.Core.Net` (Core.Net may reference Core.Physics; the
reverse is forbidden by Code Structure Rule #2). Ports `RawMotionState::Pack`
verbatim:
1. Build flags dword: set bit *only* when the field **differs from its default**
(`forward_speed != 1.0f`, `current_holdkey != None`, etc.), OR in
`num_actions << 11`. **Note the bitfield is bits 015 only** — bits 1631 are
unused (retail `num_actions` is 5 bits, max 31). The current
`MoveToState.cs` comment "bits 1131 = command list length" is wrong; correct
it to bits 1115 = `num_actions`.
2. Write the flags dword.
3. Write each set field in bit order (holdkey, style, fwd_cmd, fwd_holdkey,
fwd_speed, ss_cmd, ss_holdkey, ss_speed, turn_cmd, turn_holdkey, turn_speed).
4. Write each action: `u16 command` then
`u16 (stamp & 0x7FFF) | (autonomous ? 0x8000 : 0)` (decomp lines 293945293960).
`MoveToState.Build` is refactored to take a `RawMotionState` (plus position,
sequences, contact, standingLongjump) instead of the flat nullable param list.
**Behavioral effect vs ACE:** the only runtime change is *removing* over-sent
defaults (`forwardSpeed=1.0`, `currentHoldKey=None`, default per-axis hold keys).
ACE's `UnPackNet` defaults omitted fields, so this is strictly more correct. The
slice does **not** change which RawMotionState values the caller constructs
(that is D6) — it threads the same values the caller passes today through the new
struct. Smoke-test against ACE confirms no regression.
### 3. MoveToState trailing byte (D3)
From `MoveToStatePack::Pack` (`0x005168f0`, lines 284717284722): the trailing
byte is `((longjump_mode != 0 ? 0x02 : 0) | (contact != 0 ? 0x01 : 0))`. Then
ALIGN to 4.
`MoveToState.Build` gains explicit `bool contact` and `bool standingLongjump`
params. `standingLongjump` is wired to its true current value (`false` — standing
longjump is not implemented yet). The *packing* is faithful; the *input* is
honest. When standing longjump lands (later), the param flips. This is not a
masking shortcut — it is the correct byte with the correct current input.
### 4. JumpPack (D4)
From `JumpPack::Pack` (`0x00516d10`, lines 284934284963), field order:
1. `f32 extent`
2. `f32 velocity.x, velocity.y, velocity.z`
3. `Position.Pack` (objcell_id + frame)
4. `u16 instance_timestamp, server_control_timestamp, teleport_timestamp, force_position_ts`
5. ALIGN to 4
`JumpAction.Build` is rewritten to this layout. The current spurious
`u32 objectGuid` + `u32 spellId` are **removed** (no retail evidence). New params:
`cellId`, `position`, `rotation`. Caller at `GameWindow.cs:8343` supplies them
(it already has `wireCellId` / `wirePos` / `wireRot` in scope).
### 5. AutonomousPosition (D5 audit + timestamp order)
`AutonomousPositionPack::Pack` (`0x00516af0`): Position, then `u16` instance /
server_control / teleport / force_position, then a **contact-only** byte
(`contact != 0`; no longjump bit), then ALIGN. Current code matches. Add:
- a timestamp-**order** golden test (instance, serverControl, teleport,
forcePosition);
- a contact-byte test.
**D5 audit (no behavior change unless decomp proves divergence):** read
`CommandInterpreter::SendMovementEvent` (`0x006b4680`) and `SendPositionEvent`
(`0x006b4770`) and compare what they stamp (`last_sent_position_time` vs
position vs contact_plane) to acdream's `NotePositionSent`. Document the finding;
change behavior only if the decomp shows a real divergence.
### 6. Position::Pack verification
`Position::Pack` is shared by all three messages and currently writes
`cellId, x, y, z, qw, qx, qy, qz`. It works against ACE, but for strict parity:
decompile `Position::Pack` / `Frame::Pack` by address, confirm the field order,
and lock it with a golden test on the Position block. Fix only if the decomp
diverges; otherwise the test pins it.
## Test inventory (written first, failing)
Core.Net.Tests:
- `RawMotionStatePackTests` — default state (only the flags dword, value `0`);
walk-forward speed 1.0 (forward_command + forward_holdkey set, **forward_speed
omitted**); run forward (holdkey + fwd_cmd + fwd_holdkey + fwd_speed); backward;
sidestep right/left; turn right/left; non-default current_style; a populated
actions list (num_actions bits + per-action u16 pair); contact/longjump
trailing byte combinations.
- `MoveToStateGoldenTests` — full-message golden bytes for walk, run, slow-walk
(toggle-run), sidestep, turn, contact, standing-longjump.
- `JumpActionTests` — retail `JumpPack` layout, full Position present, no
object/spell fields.
- `AutonomousPositionTests` — timestamp order + contact byte.
- `PositionPackTests` — Position block byte order.
Core.Tests:
- `MotionCommandCatalogTests` — ACE-mode + 2013-mode matrices; class-priority
collisions; the "override deleted" assertion (or per-command cited override).
- `MotionCommandCatalogDatTests` — real-DAT availability assertions.
Golden bytes are computed by hand from the decomp `Pack` functions (cited by
address in each test) and cross-checked against holtburger's Rust packers.
## Layering & placement
- `IMotionCommandCatalog`, `AceModernCommandCatalog`, `Retail2013CommandCatalog`,
`RawMotionState``src/AcDream.Core/Physics/`.
- `RawMotionState` packer + the three message builders → `src/AcDream.Core.Net/Messages/`.
- Caller wiring → `src/AcDream.App/Rendering/GameWindow.cs` (MoveToState ~8277,
Jump ~8343) — minimal: build a `RawMotionState`, pass new contact/longjump and
jump position params. No new feature body in `GameWindow` (Code Structure Rule #1).
- Tests in the project matching the layer (Rule #6).
## Deferred / unresolved (called out, not guessed)
- **D6 (next slice):** the runtime does not yet construct the exactly-retail
`RawMotionState` (run-vs-walk `forward_command`, hold-key derivation, backward
/ sidestep-left speed normalization). This slice makes the *packer* verbatim;
state construction is `adjust_motion`/`apply_raw_movement` (audit Phase 2).
- **D5:** heartbeat-clock stamping is *audited* here, changed only if proven.
- **D2:** actions-list / current_style packing is structurally supported and
tested, but the runtime emits 0 actions; populated emission is Phase 2+.
- **D9/D8/D10/D11/D12, D7:** later L.1/L.2 slices.
## Bookkeeping (mandatory)
- **Divergence register:** delete the rows for D1, D3, D4 in the same commit that
ports each. If any new deviation is introduced, add its row in the same commit.
- **Roadmap:** update L.2b (wire) and L.1b (catalog) "shipped" notes when landed.
- **Memory:** if a durable lesson emerges (e.g., the default-difference packing
pattern, the dual-catalog seam), capture it.
## Acceptance criteria
- Failing oracle tests written first; then green after implementation.
- `dotnet build` + `dotnet test` green at each commit.
- Every ported algorithm cites the named retail symbol + address in test/comment.
- ACE smoke pass: login, walk/run/sidestep/turn/jump, observe no server
rejection; confirm the over-sent defaults are gone on the wire (dump or capture).
- Divergence register rows for D1/D3/D4 retired; roadmap updated.