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>
15 KiB
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+3low-word shift beginning atSnowAngelState).- 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 athttp://127.0.0.1:8081(decompile-by-address). - Plans of record this slice serves:
docs/plans/animation-system-audit.md(L.1) anddocs/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::Packdefault-difference flag packing (replaces presence-based flags). - D3 —
MoveToStatePacktrailing byte =contact | standingLongjump. - D4 —
JumpPacklayout (extent, velocity, full Position, four timestamps; drop the spurious objectGuid/spellId). - Catalog gap — dual command catalog (
AceModernCommandCatalogruntime default +Retail2013CommandCatalogconformance), built behind anIMotionCommandCatalogseam; full 2013command_ids[0x198]extraction; DAT-availability tests. - Phase 0 oracle tests — written first and failing, encoding the four retail
Packfunctions 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)
- First slice = catalog + outbound wire (not catalog-only, not + motion core).
- 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).
- Catalog mechanism = Option A —
IMotionCommandCataloginterface; staticMotionCommandResolver.ReconstructFullCommand(ushort)is retained, delegating to anAceModernCommandCatalogsingleton so all ~10 runtime call sites are unchanged. Tests instantiateRetail2013CommandCatalogexplicitly. - 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.ApplyNamedRetailOverridesforce-maps0x016E–0x0197to0x10000000 | lo. OnceAceModernCommandCatalogis 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), notAllegianceHometownRecall.
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)andHouseRecallexist in local DAT MotionTables;MarketplaceRecall / AllegianceHometownRecall / PKArenaRecall / OffhandSlashHighexist 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:
- Build flags dword: set bit only when the field differs from its default
(
forward_speed != 1.0f,current_holdkey != None, etc.), OR innum_actions << 11. Note the bitfield is bits 0–15 only — bits 16–31 are unused (retailnum_actionsis 5 bits, max 31). The currentMoveToState.cscomment "bits 11–31 = command list length" is wrong; correct it to bits 11–15 =num_actions. - Write the flags dword.
- 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).
- Write each action:
u16 commandthenu16 (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:
f32 extentf32 velocity.x, velocity.y, velocity.zPosition.Pack(objcell_id + frame)u16 instance_timestamp, server_control_timestamp, teleport_timestamp, force_position_ts- 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, value0); 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— retailJumpPacklayout, 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/.RawMotionStatepacker + the three message builders →src/AcDream.Core.Net/Messages/.- Caller wiring →
src/AcDream.App/Rendering/GameWindow.cs(MoveToState ~8277, Jump ~8343) — minimal: build aRawMotionState, pass new contact/longjump and jump position params. No new feature body inGameWindow(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-walkforward_command, hold-key derivation, backward / sidestep-left speed normalization). This slice makes the packer verbatim; state construction isadjust_motion/apply_raw_movement(audit Phase 2). - D5 (CONFIRMED 2026-06-30, deferred): the audit found a real divergence —
retail's
SendMovementEvent(0x006b4680) stamps ONLYlast_sent_position_timeafter an MTS, whileSendPositionEvent(0x006b4770, AP) stamps all three (time + position + contact-plane); acdream'sNotePositionSentstamps all three after both. Broader: acdream gates APs on a plain interval (HeartbeatDue) where retail usesShouldSendPositionEvent(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 (portShouldSendPositionEvent+ 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 testgreen 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.