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>
292 lines
15 KiB
Markdown
292 lines
15 KiB
Markdown
# 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 (D1–D12) and the Phase 0–5 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 `0x016E–0x0197`
|
||
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 11–15) |
|
||
|
||
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 0–15 only** — bits 16–31 are
|
||
unused (retail `num_actions` is 5 bits, max 31). The current
|
||
`MoveToState.cs` comment "bits 11–31 = command list length" is wrong; correct
|
||
it to bits 11–15 = `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 293945–293960).
|
||
|
||
`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 284717–284722): 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 284934–284963), 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.
|