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>
This commit is contained in:
Erik 2026-06-30 22:30:01 +02:00
parent 2c8620ea94
commit 78e163a41e
15 changed files with 1173 additions and 212 deletions

View file

@ -9,18 +9,27 @@ namespace AcDream.Core.Net.Messages;
/// the extent + velocity to validate the jump and replicate it to nearby
/// clients.
///
/// Wire layout (from holtburger JumpActionData::pack):
/// u32 0xF7B1 (GameAction envelope)
/// u32 sequence
/// u32 0xF61B (Jump sub-opcode)
/// f32 extent (0.01.0 charge power)
/// f32 velocity.x, f32 velocity.y, f32 velocity.z
/// u16 instanceSequence
/// u16 serverControlSequence
/// u16 teleportSequence
/// u16 forcePositionSequence
/// u32 objectGuid (0 for normal jump)
/// u32 spellId (0 for normal jump)
/// <para>
/// Wire layout — ports retail's <c>JumpPack::Pack</c> verbatim
/// (<c>0x00516d10</c>, decomp lines ~284934-284963). Confirmed against the
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61B</item>
/// <item><b>f32 extent</b> (0.0-1.0 charge power)</item>
/// <item><b>f32 velocity.x, velocity.y, velocity.z</b></item>
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition</item>
/// <item><b>Align to 4 bytes</b></item>
/// </list>
///
/// <para>
/// <b>D4 fix.</b> Retail's <c>JumpPack</c> does NOT pack an objectGuid or a
/// spellId, and DOES pack the full <c>Position</c> — the pre-slice code had
/// it backwards (two spurious trailing zero u32s, no Position at all).
/// </para>
/// </summary>
public static class JumpAction
{
@ -28,29 +37,45 @@ public static class JumpAction
public const uint JumpOpcode = 0xF61Bu;
public static byte[] Build(
uint gameActionSequence,
float extent,
Vector3 velocity,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence)
uint gameActionSequence,
float extent,
Vector3 velocity,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence)
{
var w = new PacketWriter(48);
var w = new PacketWriter(80);
w.WriteUInt32(GameActionOpcode);
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(JumpOpcode);
w.WriteFloat(extent);
w.WriteFloat(velocity.X);
w.WriteFloat(velocity.Y);
w.WriteFloat(velocity.Z);
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
w.WriteUInt32(cellId);
w.WriteFloat(position.X);
w.WriteFloat(position.Y);
w.WriteFloat(position.Z);
// Quaternion wire order: W, X, Y, Z
w.WriteFloat(rotation.W);
w.WriteFloat(rotation.X);
w.WriteFloat(rotation.Y);
w.WriteFloat(rotation.Z);
w.WriteUInt16(instanceSequence);
w.WriteUInt16(serverControlSequence);
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
w.WriteUInt32(0); // objectGuid — 0 for normal jump
w.WriteUInt32(0); // spellId — 0 for normal jump
w.AlignTo4();
return w.ToArray();
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
@ -11,61 +12,39 @@ namespace AcDream.Core.Net.Messages;
/// state and to drive interpolated position for other nearby clients.
///
/// <para>
/// Wire layout (ported from
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs</c>
/// <c>MoveToStateActionData::pack</c> and
/// <c>types.rs RawMotionState::pack</c>):
/// Wire layout — ports retail's <c>MoveToStatePack::Pack</c> verbatim
/// (<c>0x005168f0</c>, decomp lines ~284694-284722). Confirmed against the
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61C</item>
/// <item><b>RawMotionState</b>: u32 packed_flags (bits 0-10 = flag bits,
/// bits 11-31 = command list length), then conditional u32/f32 fields
/// in flag-bit order (see <c>RawMotionFlags</c> in types.rs)</item>
/// <item><b>WorldPosition</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ</item>
/// <item><b>RawMotionState::Pack</b>: see <see cref="RawMotionStatePacker"/>
/// — default-difference flags dword + conditional fields + actions.</item>
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition</item>
/// <item><b>Contact byte</b>: u8 (1 = on ground, 0 = airborne)</item>
/// <item><b>Trailing byte</b>: <c>(standingLongjump ? 0x02 : 0) |
/// (contact ? 0x01 : 0)</c> — <c>MoveToStatePack::Pack</c> trailing
/// byte expression <c>(longjump_mode == 0) - 1U &amp; 2 |
/// contact != 0</c>.</item>
/// <item><b>Align to 4 bytes</b></item>
/// </list>
///
/// <para>
/// The command list length is packed into bits 11-31 of the flags dword.
/// We always send 0 commands (no discrete motion events), so those bits stay 0.
/// </para>
/// </summary>
public static class MoveToState
{
public const uint GameActionOpcode = 0xF7B1u;
public const uint MoveToStateAction = 0xF61Cu;
// RawMotionFlags bit positions (from holtburger types.rs)
private const uint FlagCurrentHoldKey = 0x001u;
private const uint FlagCurrentStyle = 0x002u;
private const uint FlagForwardCommand = 0x004u;
private const uint FlagForwardHoldKey = 0x008u;
private const uint FlagForwardSpeed = 0x010u;
private const uint FlagSidestepCommand = 0x020u;
private const uint FlagSidestepHoldKey = 0x040u;
private const uint FlagSidestepSpeed = 0x080u;
private const uint FlagTurnCommand = 0x100u;
private const uint FlagTurnHoldKey = 0x200u;
private const uint FlagTurnSpeed = 0x400u;
/// <summary>
/// Build a MoveToState GameAction body.
/// </summary>
/// <param name="gameActionSequence">Monotonically increasing counter from
/// <see cref="WorldSession.NextGameActionSequence"/>.</param>
/// <param name="forwardCommand">Raw motion command (u32 in AC's command space),
/// e.g. 0x45000005 = WalkForward. Null = no forward command.</param>
/// <param name="forwardSpeed">Normalised speed scalar (0.0-1.0). Only written
/// when <paramref name="forwardCommand"/> is non-null.</param>
/// <param name="sidestepCommand">Sidestep command or null.</param>
/// <param name="sidestepSpeed">Sidestep speed or null.</param>
/// <param name="turnCommand">Turn command or null.</param>
/// <param name="turnSpeed">Turn speed or null.</param>
/// <param name="holdKey">Hold-key state (1=None, 2=Run). Null = omit.</param>
/// <param name="rawMotionState">Complete raw-motion snapshot, matching
/// retail's <c>CPhysicsObj::InqRawMotionState()</c>. Fields equal to
/// <see cref="RawMotionState.Default"/> are omitted from the wire
/// (see <see cref="RawMotionStatePacker"/>).</param>
/// <param name="cellId">Landblock cell ID (u32).</param>
/// <param name="position">World-space position relative to the landblock.</param>
/// <param name="rotation">Rotation quaternion. AC wire order is W, X, Y, Z.</param>
@ -73,27 +52,22 @@ public static class MoveToState
/// <param name="serverControlSequence">Server-control sequence number.</param>
/// <param name="teleportSequence">Teleport sequence number.</param>
/// <param name="forcePositionSequence">Force-position sequence number.</param>
/// <param name="contactLongJump">1 if the character is on the ground, 0 if airborne.</param>
/// <param name="contact">True if the character is on the ground.</param>
/// <param name="standingLongjump">True during a standing (charged,
/// stationary) longjump wind-up. Not yet implemented in acdream —
/// callers pass <c>false</c> honestly until that feature lands.</param>
public static byte[] Build(
uint gameActionSequence,
uint? forwardCommand,
float? forwardSpeed,
uint? sidestepCommand,
float? sidestepSpeed,
uint? turnCommand,
float? turnSpeed,
uint? holdKey,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
byte contactLongJump = 1,
uint? forwardHoldKey = null,
uint? sidestepHoldKey = null,
uint? turnHoldKey = null)
uint gameActionSequence,
RawMotionState rawMotionState,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
bool contact = true,
bool standingLongjump = false)
{
var w = new PacketWriter(128);
@ -102,43 +76,10 @@ public static class MoveToState
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(MoveToStateAction);
// --- RawMotionState ---
// Build the flags word. Command list length (bits 11-31) is always 0.
// Field order matches holtburger's RawMotionState::pack — for any axis
// where we send a COMMAND + SPEED, retail expects the matching
// *_HOLD_KEY to accompany them (see holtburger's
// build_motion_state_raw_motion_state). Without the per-axis hold
// keys the server gets the flags but can't classify the input as a
// continuously-held key, so other players see the character sliding
// forward without an animation cycle.
uint flags = 0u;
if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
if (forwardCommand.HasValue) flags |= FlagForwardCommand;
if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey;
if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
if (turnCommand.HasValue) flags |= FlagTurnCommand;
if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey;
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
// --- RawMotionState::Pack (0x0051ed10) ---
RawMotionStatePacker.Pack(w, rawMotionState);
w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list)
// Conditional fields in RawMotionFlags bit order:
if (holdKey.HasValue) w.WriteUInt32(holdKey.Value);
// FlagCurrentStyle (0x2): not sent — we don't track stance changes here
if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value);
if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
// --- WorldPosition (32 bytes) ---
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
w.WriteUInt32(cellId);
w.WriteFloat(position.X);
w.WriteFloat(position.Y);
@ -155,8 +96,10 @@ public static class MoveToState
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
// --- Contact byte + 4-byte align ---
w.WriteByte(contactLongJump);
// --- Trailing byte (MoveToStatePack::Pack, 0x005168f0): ---
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
byte trailing = (byte)((standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0));
w.WriteByte(trailing);
w.AlignTo4();
return w.ToArray();

View file

@ -0,0 +1,98 @@
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Ports retail's <c>RawMotionState::Pack</c> verbatim
/// (<c>0x0051ed10</c>, decomp lines ~293761-294013; bitfield layout
/// <c>acclient.h RawMotionState::PackBitfield</c>, line 46474). Confirmed
/// against the Ghidra decompile-by-address bridge during this slice
/// (2026-06-30).
///
/// <para>
/// <b>D1 fix.</b> Retail does NOT set a field's bit merely because the
/// caller supplied a value — it compares every field against its retail
/// DEFAULT (<see cref="RawMotionState.Default"/>) and sets the bit (and
/// emits the field) only when the live value DIFFERS. This packer mirrors
/// that default-difference comparison exactly, field by field, in bit
/// order.
/// </para>
///
/// <para>
/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's
/// <c>num_actions</c> is a 5-bit field occupying bits 11-15, not "the rest
/// of the dword"):
/// </para>
/// <list type="table">
/// <item><term>0x001</term><description>current_holdkey != HoldKey.None</description></item>
/// <item><term>0x002</term><description>current_style != 0x8000003D</description></item>
/// <item><term>0x004</term><description>forward_command != 0x41000003</description></item>
/// <item><term>0x008</term><description>forward_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x010</term><description>forward_speed != 1.0f</description></item>
/// <item><term>0x020</term><description>sidestep_command != 0</description></item>
/// <item><term>0x040</term><description>sidestep_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x080</term><description>sidestep_speed != 1.0f</description></item>
/// <item><term>0x100</term><description>turn_command != 0</description></item>
/// <item><term>0x200</term><description>turn_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x400</term><description>turn_speed != 1.0f</description></item>
/// <item><term>0xF800</term><description>num_actions (bits 11-15, count not values)</description></item>
/// </list>
/// </summary>
public static class RawMotionStatePacker
{
private const uint FlagCurrentHoldKey = 0x001u;
private const uint FlagCurrentStyle = 0x002u;
private const uint FlagForwardCommand = 0x004u;
private const uint FlagForwardHoldKey = 0x008u;
private const uint FlagForwardSpeed = 0x010u;
private const uint FlagSidestepCommand = 0x020u;
private const uint FlagSidestepHoldKey = 0x040u;
private const uint FlagSidestepSpeed = 0x080u;
private const uint FlagTurnCommand = 0x100u;
private const uint FlagTurnHoldKey = 0x200u;
private const uint FlagTurnSpeed = 0x400u;
private const int NumActionsShift = 11;
public static void Pack(PacketWriter w, RawMotionState state)
{
var defaults = RawMotionState.Default;
uint flags = 0u;
if (state.CurrentHoldKey != defaults.CurrentHoldKey) flags |= FlagCurrentHoldKey;
if (state.CurrentStyle != defaults.CurrentStyle) flags |= FlagCurrentStyle;
if (state.ForwardCommand != defaults.ForwardCommand) flags |= FlagForwardCommand;
if (state.ForwardHoldKey != defaults.ForwardHoldKey) flags |= FlagForwardHoldKey;
if (state.ForwardSpeed != defaults.ForwardSpeed) flags |= FlagForwardSpeed;
if (state.SidestepCommand != defaults.SidestepCommand) flags |= FlagSidestepCommand;
if (state.SidestepHoldKey != defaults.SidestepHoldKey) flags |= FlagSidestepHoldKey;
if (state.SidestepSpeed != defaults.SidestepSpeed) flags |= FlagSidestepSpeed;
if (state.TurnCommand != defaults.TurnCommand) flags |= FlagTurnCommand;
if (state.TurnHoldKey != defaults.TurnHoldKey) flags |= FlagTurnHoldKey;
if (state.TurnSpeed != defaults.TurnSpeed) flags |= FlagTurnSpeed;
int numActions = state.Actions.Count;
flags |= (uint)(numActions << NumActionsShift);
w.WriteUInt32(flags);
if ((flags & FlagCurrentHoldKey) != 0) w.WriteUInt32((uint)state.CurrentHoldKey);
if ((flags & FlagCurrentStyle) != 0) w.WriteUInt32(state.CurrentStyle);
if ((flags & FlagForwardCommand) != 0) w.WriteUInt32(state.ForwardCommand);
if ((flags & FlagForwardHoldKey) != 0) w.WriteUInt32((uint)state.ForwardHoldKey);
if ((flags & FlagForwardSpeed) != 0) w.WriteFloat(state.ForwardSpeed);
if ((flags & FlagSidestepCommand) != 0) w.WriteUInt32(state.SidestepCommand);
if ((flags & FlagSidestepHoldKey) != 0) w.WriteUInt32((uint)state.SidestepHoldKey);
if ((flags & FlagSidestepSpeed) != 0) w.WriteFloat(state.SidestepSpeed);
if ((flags & FlagTurnCommand) != 0) w.WriteUInt32(state.TurnCommand);
if ((flags & FlagTurnHoldKey) != 0) w.WriteUInt32((uint)state.TurnHoldKey);
if ((flags & FlagTurnSpeed) != 0) w.WriteFloat(state.TurnSpeed);
foreach (var action in state.Actions)
{
w.WriteUInt16(action.Command);
ushort stampWord = (ushort)((action.Stamp & 0x7FFF) | (action.Autonomous ? 0x8000 : 0));
w.WriteUInt16(stampWord);
}
}
}