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

15 KiB
Raw Blame History

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

  • D1RawMotionState::Pack default-difference flag packing (replaces presence-based flags).
  • D3MoveToStatePack trailing byte = contact | standingLongjump.
  • D4JumpPack 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 AIMotionCommandCatalog 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 (0x430001150x43000118), 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, RawMotionStatesrc/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.