acdream/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.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

284 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 namevalue 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.