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

@ -8263,29 +8263,45 @@ public sealed class GameWindow : IDisposable
// semantic.
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
{
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
// Invalid = 0, None = 1, Run = 2
// HoldKey axis values — retail enum (acclient.h enum
// HoldKey): Invalid = 0, None = 1, Run = 2.
// Retail always sends CURRENT_HOLD_KEY (and uses the same
// value for every active per-axis hold key — see
// holtburger's build_motion_state_raw_motion_state).
// When the player is running forward, 2=Run; otherwise 1=None.
const uint HoldKeyNone = 1u;
const uint HoldKeyRun = 2u;
uint axisHoldKey = result.IsRunning ? HoldKeyRun : HoldKeyNone;
// When the player is running forward, Run; otherwise None.
var axisHoldKey = result.IsRunning
? AcDream.Core.Physics.HoldKey.Run
: AcDream.Core.Physics.HoldKey.None;
// D1/L.2b (2026-06-30): build the COMPLETE RawMotionState
// snapshot (matching retail's CPhysicsObj::InqRawMotionState())
// and let RawMotionStatePacker's default-difference comparison
// decide what actually goes on the wire — see
// RawMotionStatePacker (ports RawMotionState::Pack, 0x0051ed10).
// This slice changes ONLY the packing; the state values
// below are exactly what the pre-slice code passed in.
var rawMotionState = new AcDream.Core.Physics.RawMotionState
{
CurrentHoldKey = axisHoldKey,
// CurrentStyle: not tracked by acdream today — leave at
// the retail default (0x8000003D) so the packer omits it.
ForwardCommand = result.ForwardCommand ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand,
ForwardHoldKey = result.ForwardCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
ForwardSpeed = result.ForwardSpeed ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed,
SidestepCommand = result.SidestepCommand ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand,
SidestepHoldKey = result.SidestepCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
SidestepSpeed = result.SidestepSpeed ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed,
TurnCommand = result.TurnCommand ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand,
TurnHoldKey = result.TurnCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
TurnSpeed = result.TurnSpeed ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed,
// Actions: acdream does not yet emit discrete motion
// events on this path (D2, deferred to Phase 2+).
};
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.MoveToState.Build(
gameActionSequence: seq,
forwardCommand: result.ForwardCommand,
forwardSpeed: result.ForwardSpeed,
sidestepCommand: result.SidestepCommand,
sidestepSpeed: result.SidestepSpeed,
turnCommand: result.TurnCommand,
turnSpeed: result.TurnSpeed,
holdKey: axisHoldKey, // always present
forwardHoldKey: result.ForwardCommand.HasValue ? axisHoldKey : (uint?)null,
sidestepHoldKey: result.SidestepCommand.HasValue ? axisHoldKey : (uint?)null,
turnHoldKey: result.TurnCommand.HasValue ? axisHoldKey : (uint?)null,
rawMotionState: rawMotionState,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
@ -8293,15 +8309,25 @@ public sealed class GameWindow : IDisposable
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence,
contactLongJump: contactByte);
contact: contactByte != 0,
// Standing longjump is not implemented yet — honest
// current value, not a guess (D3).
standingLongjump: false);
DumpMovementTruthOutbound(
"MTS", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
// last_sent_position_time + last_sent_position + contact_plane
// after each MTS send.
// D5 audit (2026-06-30, this slice): retail's
// SendMovementEvent (acclient_2013_pseudo_c.txt, 0x006b4680)
// stamps ONLY last_sent_position_time after an MTS send — it
// does NOT update last_sent_position or
// last_sent_contact_plane (confirmed via Ghidra
// decompile-by-address during this slice). Only
// SendPositionEvent (the AP path, below) stamps all three.
// acdream's NotePositionSent call here stamps all three on
// BOTH paths — this is a real, audit-confirmed divergence
// from retail. Per this slice's scope (D5 = audit only,
// change only with explicit sign-off), left UNCHANGED;
// reported to the lead engineer instead of fixed here.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
@ -8327,9 +8353,13 @@ public sealed class GameWindow : IDisposable
_liveSession.SendGameAction(body);
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348,
// decomp address 0x006b4770, re-confirmed via the Ghidra
// decompile-by-address bridge during the L.2b slice 2026-06-30)
// writing last_sent_position_time + last_sent_position +
// last_sent_contact_plane after each AP.
// last_sent_contact_plane after each AP. Unlike the MTS path
// above (SendMovementEvent, 0x006b4680), which retail stamps
// ONLY last_sent_position_time, this AP path is correct.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
@ -8339,11 +8369,17 @@ public sealed class GameWindow : IDisposable
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
{
// D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the
// full Position, not an objectGuid/spellId — pass the same
// wireCellId/wirePos/wireRot the MoveToState send above uses.
var seq = _liveSession.NextGameActionSequence();
var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build(
gameActionSequence: seq,
extent: result.JumpExtent.Value,
velocity: result.JumpVelocity.Value,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,

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);
}
}
}

View file

@ -129,11 +129,23 @@ public enum WeenieError : uint
// ── Motion state structs ───────────────────────────────────────────────────────
/// <summary>
/// Raw (network-derived) motion state for the local player.
/// Raw (network-derived) motion state for the local player, as consumed by
/// the (still-approximate) <see cref="MotionInterpreter"/> input-state path.
/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
///
/// <para>
/// Renamed from <c>RawMotionState</c> during the L.2b wire-parity slice
/// (2026-06-30) to free that name for the retail-faithful, full-field
/// <c>AcDream.Core.Physics.RawMotionState</c> (mirrors
/// <c>acclient.h RawMotionState::PackBitfield</c>) used by the outbound
/// wire packer. This partial type is D6/Phase-2 territory (the
/// <c>adjust_motion</c>/<c>apply_raw_movement</c> input-state port) —
/// out of scope for this slice. It is expected to be folded into or
/// replaced by the retail-faithful type when D6 lands.
/// </para>
/// </summary>
public struct RawMotionState
public struct LegacyRawMotionState
{
/// <summary>Forward/backward motion command (offset +0x20).</summary>
public uint ForwardCommand;
@ -149,7 +161,7 @@ public struct RawMotionState
public float TurnSpeed;
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
public static RawMotionState Default() => new()
public static LegacyRawMotionState Default() => new()
{
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1.0f,
@ -261,7 +273,7 @@ public sealed class MotionInterpreter
public IWeenieObject? WeenieObj { get; set; }
/// <summary>Raw (network-derived) motion state (struct offsets +0x14..+0x44).</summary>
public RawMotionState RawState;
public LegacyRawMotionState RawState;
/// <summary>Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).</summary>
public InterpretedMotionState InterpretedState;
@ -318,7 +330,7 @@ public sealed class MotionInterpreter
public MotionInterpreter()
{
RawState = RawMotionState.Default();
RawState = LegacyRawMotionState.Default();
InterpretedState = InterpretedMotionState.Default();
}
@ -326,7 +338,7 @@ public sealed class MotionInterpreter
{
PhysicsObj = physicsObj;
WeenieObj = weenieObj;
RawState = RawMotionState.Default();
RawState = LegacyRawMotionState.Default();
InterpretedState = InterpretedMotionState.Default();
}

View file

@ -0,0 +1,71 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Retail hold-key enum (<c>acclient.h</c> <c>enum HoldKey</c>, line 3394).
/// </summary>
public enum HoldKey : uint
{
Invalid = 0x0,
None = 0x1,
Run = 0x2,
}
/// <summary>
/// One entry in <see cref="RawMotionState.Actions"/> — a discrete motion
/// event (jump, attack, etc.) layered on top of the continuous
/// forward/sidestep/turn axes.
///
/// Retail packs each action as <c>u16 command</c> then
/// <c>u16 ((stamp &amp; 0x7FFF) | (autonomous ? 0x8000 : 0))</c>
/// (<c>RawMotionState::Pack</c>, 0x0051ed10, decomp lines ~293998-294010).
/// </summary>
public readonly record struct RawMotionAction(ushort Command, ushort Stamp, bool Autonomous);
/// <summary>
/// Pure-data mirror of retail's <c>RawMotionState</c> struct
/// (<c>acclient.h</c> <c>RawMotionState::PackBitfield</c>, line 46474;
/// packer at <c>RawMotionState::Pack</c>, 0x0051ed10).
///
/// <para>
/// This type carries the COMPLETE motion-state snapshot the way retail's
/// <c>CPhysicsObj::InqRawMotionState()</c> would return it. The packer
/// (<see cref="AcDream.Core.Net"/>) compares every field against
/// <see cref="Default"/> and only emits the fields that differ — see
/// <c>RawMotionState::Pack</c> for the exact default-difference logic.
/// </para>
///
/// <para>
/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
/// <c>AcDream.Core.Physics</c> so the (future) motion interpreter, which is
/// also Core.Physics, can populate it without taking a Core.Net dependency
/// (Code Structure Rule #2).
/// </para>
/// </summary>
public sealed class RawMotionState
{
public HoldKey CurrentHoldKey { get; init; } = HoldKey.None;
public uint CurrentStyle { get; init; } = 0x8000003Du;
public uint ForwardCommand { get; init; } = 0x41000003u;
public HoldKey ForwardHoldKey { get; init; } = HoldKey.Invalid;
public float ForwardSpeed { get; init; } = 1.0f;
public uint SidestepCommand { get; init; } = 0u;
public HoldKey SidestepHoldKey { get; init; } = HoldKey.Invalid;
public float SidestepSpeed { get; init; } = 1.0f;
public uint TurnCommand { get; init; } = 0u;
public HoldKey TurnHoldKey { get; init; } = HoldKey.Invalid;
public float TurnSpeed { get; init; } = 1.0f;
/// <summary>
/// Discrete action list. Retail packs <c>num_actions</c> (count, not the
/// values) into bits 11-15 of the flags dword, then emits each action's
/// command+stamp pair unconditionally after the continuous-axis fields.
/// </summary>
public IReadOnlyList<RawMotionAction> Actions { get; init; } = Array.Empty<RawMotionAction>();
/// <summary>
/// Retail defaults — a field bit is set in the packed flags dword ONLY
/// when the live value differs from these (<c>RawMotionState::Pack</c>,
/// 0x0051ed10).
/// </summary>
public static readonly RawMotionState Default = new();
}