acdream/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md
Erik 78e163a41e feat(L.2b): outbound movement wire parity — RawMotionState default-difference, JumpPack, contact byte
Ports the three retail outbound-movement packers verbatim (decomp-derived
golden bytes, confirmed via the Ghidra bridge, cross-checked vs holtburger):

- D1 — RawMotionState::Pack (0x0051ed10): new AcDream.Core.Physics.RawMotionState
  data type (11 fields + actions, retail defaults) + RawMotionStatePacker that
  sets a flag bit only when the field DIFFERS from its default. MoveToState.Build
  now takes a RawMotionState instead of presence-based nullable params, so the
  over-sent forwardSpeed=1.0 / currentHoldKey=None / default per-axis holdkeys are
  no longer emitted. num_actions packs into bits 11-15 (not "bits 11-31").
- D3 — MoveToStatePack::Pack (0x005168f0) trailing byte =
  (standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0); explicit contact/
  standingLongjump params (standingLongjump=false honestly until the feature lands).
- D4 — JumpAction rewritten to retail JumpPack::Pack (0x00516d10): extent,
  velocity, full Position, four u16 timestamps, align. Removed the spurious
  objectGuid/spellId u32s; Position is now packed (it was absent). Body 56 bytes.
- Position::Pack (0x005a9640) / Frame::Pack (0x00535130) verified already-correct
  (cellId, origin xyz, quaternion wxyz); locked with a golden test, no change.

GameWindow callers adapted minimally: build the RawMotionState from the existing
MovementResult values (behavior preserved except the intended D1 omissions) and
pass cellId/position/rotation to the Jump send. Pre-existing MotionInterpreter
placeholder struct RawMotionState renamed LegacyRawMotionState (D6/Phase-2 scope,
pure rename) to free the name for the retail-faithful type.

D5 audit: confirmed a real divergence — retail SendMovementEvent (0x006b4680)
stamps only last_sent_position_time after an MTS while SendPositionEvent
(0x006b4770) stamps all three; acdream's NotePositionSent stamps all three on
both paths. Left unchanged (comments added at both call sites), recorded as
register TS-33, deferred to a dedicated cadence-port slice.

Tests: RawMotionStatePackTests / MoveToStateGoldenTests / JumpActionTests /
PositionPackTests + updated MoveToStateTests / AutonomousPositionTests. Full
suite green (Core.Net.Tests 372, full solution 3228 passed / 4 pre-existing skips).

Register: TS-24/TS-25 refreshed (packer now supports actions/style; runtime
emission still deferred), TS-33 added. Roadmap L.2b shipped note added.
Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (2-6)

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

292 lines
15 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 (CONFIRMED 2026-06-30, deferred):** the audit found a real divergence
retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time`
after an MTS, while `SendPositionEvent` (0x006b4770, AP) stamps all three
(time + position + contact-plane); acdream's `NotePositionSent` stamps all three
after both. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where
retail uses `ShouldSendPositionEvent` (0x006b45e0). Left unchanged this slice (code
comments added at both call sites); recorded as register row **TS-33** and deferred
to a dedicated cadence-port follow-up slice (port `ShouldSendPositionEvent` + split
MTS/AP stamping).
- **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.