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

@ -122,6 +122,69 @@ public class AutonomousPositionTests
Assert.Equal(0, body[52]);
}
[Fact]
public void Build_TimestampOrder_MatchesAutonomousPositionPackPack()
{
// AutonomousPositionPack::Pack (0x00516af0, confirmed via Ghidra
// decompile-by-address during this slice 2026-06-30): after
// Position::Pack (32 bytes), four u16 timestamps in order
// instance_timestamp, server_control_timestamp, teleport_timestamp,
// force_position_ts — then a CONTACT-ONLY byte (no longjump bit;
// that bit only exists in MoveToStatePack), then ALIGN_PTR.
var body = AutonomousPosition.Build(
gameActionSequence: 8,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0x1111,
serverControlSequence: 0x2222,
teleportSequence: 0x3333,
forcePositionSequence: 0x4444);
// 12 (envelope) + 32 (Position) = 44.
int tsOffset = 44;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
Assert.Equal((ushort)0x1111, instance);
Assert.Equal((ushort)0x2222, serverControl);
Assert.Equal((ushort)0x3333, teleport);
Assert.Equal((ushort)0x4444, forcePosition);
}
[Fact]
public void Build_ContactByte_IsContactOnly_NoLongjumpBit()
{
// contact != 0 -> byte = 1 (bool cast, not an OR'd bitmask like MTS).
var bodyOnGround = AutonomousPosition.Build(
gameActionSequence: 9,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
lastContact: 1);
Assert.Equal(1, bodyOnGround[52]);
var bodyAirborne = AutonomousPosition.Build(
gameActionSequence: 10,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
lastContact: 0);
Assert.Equal(0, bodyAirborne[52]);
}
[Fact]
public void Build_ContainsIdentityRotation_AfterPosition()
{

View file

@ -0,0 +1,170 @@
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte tests for the retail-faithful <see cref="JumpAction.Build"/>
/// layout, porting <c>JumpPack::Pack</c> (0x00516d10, decomp lines
/// ~284934-284963). Confirmed verbatim against the Ghidra decompile-by-address
/// bridge (http://127.0.0.1:8081/decompile_function?address=0x00516d10)
/// during this slice (2026-06-30):
///
/// <code>
/// extent (f32), velocity.x/y/z (f32 x3), Position::Pack (cellId + Frame),
/// instance_timestamp (u16), server_control_timestamp (u16),
/// teleport_timestamp (u16), force_position_ts (u16), ALIGN_PTR.
/// </code>
///
/// D4: retail does NOT pack an objectGuid or spellId. The pre-slice acdream
/// code wrote two spurious trailing <c>u32 0</c> fields and omitted Position
/// entirely — both are fixed here.
/// </summary>
public class JumpActionTests
{
[Fact]
public void Build_ProducesValidGameAction()
{
var body = JumpAction.Build(
gameActionSequence: 9,
extent: 0.5f,
velocity: new Vector3(1f, 2f, 3f),
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0xF7B1u, opcode);
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(9u, seq);
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF61Bu, actionType);
}
[Fact]
public void Build_ExtentAndVelocity_FollowEnvelope()
{
var body = JumpAction.Build(
gameActionSequence: 1,
extent: 0.75f,
velocity: new Vector3(1.5f, -2.5f, 9.81f),
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// 12-byte envelope, then extent(4), vx(4), vy(4), vz(4).
float extent = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12));
float vx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16));
float vy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20));
float vz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
Assert.Equal(0.75f, extent);
Assert.Equal(1.5f, vx);
Assert.Equal(-2.5f, vy);
Assert.Equal(9.81f, vz);
}
[Fact]
public void Build_PositionFollowsVelocity_CellIdThenOriginThenQuaternion()
{
var body = JumpAction.Build(
gameActionSequence: 2,
extent: 0f,
velocity: Vector3.Zero,
cellId: 0xDEADBEEFu,
position: new Vector3(12.5f, 34.0f, 56.75f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// 12 (envelope) + 4 (extent) + 12 (velocity) = offset 28 -> Position::Pack.
int positionOffset = 28;
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(positionOffset));
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 4));
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 8));
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 12));
float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 16));
float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 20));
float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 24));
float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 28));
Assert.Equal(0xDEADBEEFu, cellId);
Assert.Equal(12.5f, x);
Assert.Equal(34.0f, y);
Assert.Equal(56.75f, z);
Assert.Equal(1.0f, qw);
Assert.Equal(0.0f, qx);
Assert.Equal(0.0f, qy);
Assert.Equal(0.0f, qz);
}
[Fact]
public void Build_TimestampsFollowPosition_InRetailOrder()
{
var body = JumpAction.Build(
gameActionSequence: 3,
extent: 0f,
velocity: Vector3.Zero,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0x1111,
serverControlSequence: 0x2222,
teleportSequence: 0x3333,
forcePositionSequence: 0x4444);
// Position::Pack is 32 bytes (cellId + Frame). Timestamps start at
// 28 (extent/velocity/envelope) + 32 = 60.
int tsOffset = 60;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
Assert.Equal((ushort)0x1111, instance);
Assert.Equal((ushort)0x2222, serverControl);
Assert.Equal((ushort)0x3333, teleport);
Assert.Equal((ushort)0x4444, forcePosition);
}
[Fact]
public void Build_NoObjectGuidOrSpellId_JumpPackBodyLengthIs56()
{
// JumpPack::Pack body (everything AFTER the 12-byte GameAction
// envelope): extent(4) + velocity(12) + Position(32) +
// 4x u16 timestamps(8) = 56 bytes, already a multiple of 4 -> no
// align padding. Total wire length = 12 (envelope) + 56 = 68.
// Retail's JumpPack has NO objectGuid/spellId fields (D4) — the
// pre-slice code's two spurious trailing u32 writes are gone.
var body = JumpAction.Build(
gameActionSequence: 4,
extent: 0f,
velocity: Vector3.Zero,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(56, body.Length - 12);
Assert.Equal(68, body.Length);
Assert.Equal(0, body.Length % 4);
}
}

View file

@ -0,0 +1,254 @@
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Full-message golden-byte tests for the refactored <see cref="MoveToState.Build"/>
/// (D1 default-difference RawMotionState packing + D3 trailing byte), porting
/// <c>MoveToStatePack::Pack</c> (0x005168f0, decomp lines ~284694-284722).
/// Confirmed verbatim against the Ghidra decompile-by-address bridge during
/// this slice (2026-06-30):
///
/// <code>
/// MoveToStatePack::Pack:
/// RawMotionState::Pack(...)
/// Position::Pack(...)
/// instance_timestamp(u16), server_control_timestamp(u16),
/// teleport_timestamp(u16), force_position_ts(u16)
/// trailing byte = ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
/// ALIGN_PTR
/// </code>
/// </summary>
public class MoveToStateGoldenTests
{
private static readonly Vector3 Pos = new(96f, 96f, 50f);
private static readonly Quaternion Rot = Quaternion.Identity;
[Fact]
public void Build_DefaultRawMotionState_FlagsAreZero_EnvelopePlusPositionPlusTimestampsPlusTrailingByte()
{
var body = MoveToState.Build(
gameActionSequence: 1,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
// 12 (envelope) + 4 (flags=0, no fields) + 32 (Position) + 8 (timestamps)
// + 1 (trailing byte) = 57, aligned to 60.
Assert.Equal(60, body.Length);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0u, flags);
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
Assert.Equal(0xA9B40001u, cellId);
// trailing byte at 12+4+32+8 = 56: contact=true, longjump=false -> 0x01
Assert.Equal(0x01, body[56]);
}
[Fact]
public void Build_WalkForward_OmitsForwardSpeedDefault()
{
// Walk-forward at default speed 1.0 -> forward_speed bit OMITTED (D1 fix).
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.None, // default, omitted
ForwardCommand = 0x45000005u, // WalkForward
ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
ForwardSpeed = 1.0f, // default, omitted
};
var body = MoveToState.Build(
gameActionSequence: 2,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x0000000Cu, flags); // ForwardCommand | ForwardHoldKey only
// RawMotionState body = flags(4) + fwd_cmd(4) + fwd_holdkey(4) = 12.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 12));
Assert.Equal(0xA9B40001u, cellId);
}
[Fact]
public void Build_RunForward_IncludesHoldKeyAndSpeed()
{
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.Run,
ForwardCommand = 0x44000007u, // RunForward
ForwardHoldKey = HoldKey.Run,
ForwardSpeed = 2.94f,
};
var body = MoveToState.Build(
gameActionSequence: 3,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x0000001Du, flags); // holdkey | fwd_cmd | fwd_holdkey | fwd_speed
// RawMotionState body = flags(4) + holdkey(4) + fwd_cmd(4) + fwd_holdkey(4) + fwd_speed(4) = 20.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 20));
Assert.Equal(0xA9B40001u, cellId);
}
[Fact]
public void Build_Sidestep_SetsSidestepBitsOnly()
{
var state = new RawMotionState
{
SidestepCommand = 0x6500000Fu, // SideStepRight
SidestepHoldKey = HoldKey.None,
SidestepSpeed = 1.0f, // default -> omitted
};
var body = MoveToState.Build(
gameActionSequence: 4,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x00000060u, flags); // SidestepCommand(0x20) | SidestepHoldKey(0x40)
}
[Fact]
public void Build_Turn_SetsTurnBitsOnly()
{
var state = new RawMotionState
{
TurnCommand = 0x6500000Du, // TurnRight
TurnHoldKey = HoldKey.None,
TurnSpeed = 1.0f, // default -> omitted
};
var body = MoveToState.Build(
gameActionSequence: 5,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x00000300u, flags); // TurnCommand(0x100) | TurnHoldKey(0x200)
}
[Theory]
[InlineData(false, false, 0x00)]
[InlineData(true, false, 0x01)]
[InlineData(false, true, 0x02)]
[InlineData(true, true, 0x03)]
public void Build_TrailingByte_AllFourContactLongjumpCombinations(bool contact, bool standingLongjump, byte expected)
{
// MoveToStatePack::Pack trailing byte:
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
var body = MoveToState.Build(
gameActionSequence: 6,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: contact,
standingLongjump: standingLongjump);
// flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56.
Assert.Equal(expected, body[56]);
}
[Fact]
public void Build_TimestampOrder_MatchesMoveToStatePackPack()
{
var body = MoveToState.Build(
gameActionSequence: 7,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0x1111,
serverControlSequence: 0x2222,
teleportSequence: 0x3333,
forcePositionSequence: 0x4444,
contact: true,
standingLongjump: false);
// 12 (envelope) + 4 (flags) + 32 (Position) = 48.
int tsOffset = 48;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
Assert.Equal((ushort)0x1111, instance);
Assert.Equal((ushort)0x2222, serverControl);
Assert.Equal((ushort)0x3333, teleport);
Assert.Equal((ushort)0x4444, forcePosition);
}
[Fact]
public void Build_IsAlignedTo4Bytes()
{
var body = MoveToState.Build(
gameActionSequence: 8,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
Assert.Equal(0, body.Length % 4);
}
}

View file

@ -2,26 +2,36 @@ using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Envelope + structural tests for <see cref="MoveToState.Build"/>. Golden
/// byte-layout tests for the RawMotionState default-difference packing (D1)
/// and the trailing contact/longjump byte (D3) live in
/// <see cref="MoveToStateGoldenTests"/> and <see cref="RawMotionStatePackTests"/>.
///
/// D1/D3 refactor (2026-06-30): <c>Build</c> now takes a
/// <see cref="RawMotionState"/> snapshot (matching retail's
/// <c>CPhysicsObj::InqRawMotionState()</c>) instead of flat nullable
/// per-axis params, plus explicit <c>contact</c>/<c>standingLongjump</c>
/// booleans for the trailing byte — see <c>MoveToStatePack::Pack</c>
/// (0x005168f0).
/// </summary>
public class MoveToStateTests
{
private static readonly Vector3 Pos = new(96f, 96f, 50f);
[Fact]
public void Build_IdleState_ProducesValidGameAction()
{
var body = MoveToState.Build(
gameActionSequence: 1,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
position: Pos,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
@ -42,31 +52,33 @@ public class MoveToStateTests
}
[Fact]
public void Build_WalkForward_IncludesForwardCommandInFlags()
public void Build_WalkForward_DefaultSpeedOmitted_OnlyCommandAndHoldKeyFlagsSet()
{
// D1 fix: forward_speed == 1.0 (the retail default) is OMITTED from
// the flags, unlike the pre-slice presence-based packer which always
// set the ForwardSpeed bit whenever a caller supplied a value.
var state = new RawMotionState
{
ForwardCommand = 0x45000005u, // WalkForward
ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
ForwardSpeed = 1.0f, // default -> omitted
};
var body = MoveToState.Build(
gameActionSequence: 2,
forwardCommand: 0x45000005u, // WalkForward
forwardSpeed: 1.0f,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: state,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
position: Pos,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// After the 12-byte GameAction header comes RawMotionState.
// First u32 is the packed flags word. ForwardCommand flag = 0x4.
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.True((flags & 0x4u) != 0, "ForwardCommand flag (0x4) should be set");
// ForwardSpeed flag = 0x10
Assert.True((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) should be set");
Assert.True((flags & 0x8u) != 0, "ForwardHoldKey flag (0x8) should be set");
Assert.False((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) must be OMITTED at the retail default 1.0");
}
[Fact]
@ -74,13 +86,7 @@ public class MoveToStateTests
{
var body = MoveToState.Build(
gameActionSequence: 3,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -94,19 +100,14 @@ public class MoveToStateTests
}
[Fact]
public void Build_IdleState_WorldPositionFollowsMotionState()
public void Build_IdleState_WorldPositionFollowsZeroFlagMotionState()
{
// With no motion state, flags = 0 and no conditional fields are written.
// So WorldPosition starts at offset 12 (envelope) + 4 (flags) = 16.
// With the default raw motion state, flags = 0 and no conditional
// fields are written. So WorldPosition starts at offset 12
// (envelope) + 4 (flags) = 16.
var body = MoveToState.Build(
gameActionSequence: 4,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -124,13 +125,7 @@ public class MoveToStateTests
{
var body = MoveToState.Build(
gameActionSequence: 5,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -143,17 +138,11 @@ public class MoveToStateTests
}
[Fact]
public void Build_UsesExplicitAirborneContactByte()
public void Build_UsesExplicitAirborneContact()
{
var body = MoveToState.Build(
gameActionSequence: 7,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -161,23 +150,20 @@ public class MoveToStateTests
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contactLongJump: 0);
contact: false);
// flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56.
Assert.Equal(0, body[56]);
}
[Fact]
public void Build_WithHoldKey_IncludesHoldKeyFlag()
{
var state = new RawMotionState { CurrentHoldKey = HoldKey.Run };
var body = MoveToState.Build(
gameActionSequence: 6,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: 2u, // Run
rawMotionState: state,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,

View file

@ -0,0 +1,63 @@
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Pins the shared WorldPosition/Position block byte order used by
/// <see cref="MoveToState"/>, <see cref="AutonomousPosition"/>, and
/// <see cref="JumpAction"/>: <c>Position::Pack</c> (0x005a9640) wraps
/// <c>cellId(u32)</c> then <c>Frame::Pack</c> (0x00535130) =
/// <c>origin.x/y/z(f32)</c> then <c>qw/qx/qy/qz(f32)</c>. Confirmed verbatim
/// against the Ghidra decompile-by-address bridge during this slice
/// (2026-06-30):
///
/// <code>
/// Position::Pack: objcell_id(u32), Frame::Pack(...)
/// Frame::Pack: m_fOrigin.x/y/z(f32 x3), qw(f32), qx(f32), qy(f32), qz(f32)
/// </code>
///
/// 32 bytes total (4 + 12 + 16). This was already correct pre-slice — these
/// tests lock the byte order with a golden-value assertion rather than
/// changing behavior.
/// </summary>
public class PositionPackTests
{
[Fact]
public void MoveToState_PositionBlock_OrderIsCellIdThenOriginThenQuaternion()
{
var body = MoveToState.Build(
gameActionSequence: 1,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: new Vector3(1.5f, 2.5f, 3.5f),
rotation: new Quaternion(0.1f, 0.2f, 0.3f, 0.9f), // X,Y,Z,W ctor order
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// No motion state -> flags(4) only before the Position block at offset 16.
int off = 12 + 4;
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(off));
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 4));
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 8));
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 12));
float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 16));
float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 20));
float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 24));
float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 28));
Assert.Equal(0xA9B40001u, cellId);
Assert.Equal(1.5f, x);
Assert.Equal(2.5f, y);
Assert.Equal(3.5f, z);
Assert.Equal(0.9f, qw);
Assert.Equal(0.1f, qx);
Assert.Equal(0.2f, qy);
Assert.Equal(0.3f, qz);
}
}

View file

@ -0,0 +1,212 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte tests for <see cref="RawMotionStatePacker.Pack"/>, porting
/// retail's <c>RawMotionState::Pack</c> (0x0051ed10, decomp lines
/// ~293761-294013; bitfield layout <c>acclient.h RawMotionState::PackBitfield</c>,
/// line 46474). Confirmed verbatim against the Ghidra decompile-by-address
/// bridge (http://127.0.0.1:8081/decompile_function?address=0x0051ed10)
/// during this slice (2026-06-30).
///
/// <para>
/// Retail compares every field against its DEFAULT and only sets the
/// matching bit (and emits the field) when the live value DIFFERS. This is
/// the D1 fix — the old presence-based packer over-sent defaulted fields.
/// </para>
/// </summary>
public class RawMotionStatePackTests
{
private static byte[] Pack(RawMotionState state)
{
var w = new PacketWriter(64);
RawMotionStatePacker.Pack(w, state);
return w.ToArray();
}
[Fact]
public void Pack_DefaultState_EmitsOnlyZeroFlags()
{
// Every field equals its retail default -> flags dword is 0,
// no conditional fields, no actions. 4 bytes total.
var body = Pack(RawMotionState.Default);
Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, body);
}
[Fact]
public void Pack_ShiftWalk_OmitsForwardSpeedAndCurrentHoldKey()
{
// Shift-walk: current_holdkey stays None (default omitted),
// forward_command = WalkForward (0x45000005), forward_holdkey =
// None (1) -- DIFFERS from default Invalid(0) so IS sent --
// forward_speed stays 1.0 (default, omitted). Rest default.
//
// Flags expected: ForwardCommand(0x004) | ForwardHoldKey(0x008) = 0x00C.
// Body: flags(u32) + forward_command(u32) + forward_holdkey(u32).
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.None, // default -> omitted
ForwardCommand = 0x45000005u, // WalkForward -> differs -> set
ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
ForwardSpeed = 1.0f, // default -> omitted (the D1 fix)
};
var body = Pack(state);
Assert.Equal(12, body.Length);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x0000000Cu, flags);
uint fwdCommand = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(0x45000005u, fwdCommand);
uint fwdHoldKey = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(1u, fwdHoldKey); // HoldKey.None
byte[] expected =
{
0x0C, 0x00, 0x00, 0x00,
0x05, 0x00, 0x00, 0x45,
0x01, 0x00, 0x00, 0x00,
};
Assert.Equal(expected, body);
}
[Fact]
public void Pack_RunForward_SetsHoldKeyForwardCommandHoldKeyAndSpeed()
{
// Run-forward: current_holdkey = Run(2) (differs from None -> set),
// forward_command = RunForward-ish 0x44000007 (differs -> set),
// forward_holdkey = Run(2) (differs from Invalid -> set),
// forward_speed = 3.0 (differs from 1.0 -> set).
//
// Flags expected: CurrentHoldKey(0x001) | ForwardCommand(0x004) |
// ForwardHoldKey(0x008) | ForwardSpeed(0x010) = 0x01D.
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.Run,
ForwardCommand = 0x44000007u,
ForwardHoldKey = HoldKey.Run,
ForwardSpeed = 3.0f,
};
var body = Pack(state);
Assert.Equal(20, body.Length);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x0000001Du, flags);
byte[] expected =
{
0x1D, 0x00, 0x00, 0x00, // flags
0x02, 0x00, 0x00, 0x00, // current_holdkey = Run(2)
0x07, 0x00, 0x00, 0x44, // forward_command = 0x44000007
0x02, 0x00, 0x00, 0x00, // forward_holdkey = Run(2)
0x00, 0x00, 0x40, 0x40, // forward_speed = 3.0f (0x40400000 LE)
};
Assert.Equal(expected, body);
}
[Fact]
public void Pack_NonDefaultCurrentStyle_SetsStyleBitAndEmitsValue()
{
// current_style differs from 0x8000003D -> bit 0x002 set, value emitted
// immediately after the flags dword (bit order: holdkey, style, ...).
var state = new RawMotionState
{
CurrentStyle = 0x80000042u,
};
var body = Pack(state);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x002u, flags);
uint style = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(0x80000042u, style);
Assert.Equal(8, body.Length);
}
[Fact]
public void Pack_PopulatedActionsList_SetsNumActionsBitsAndEmitsPairs()
{
// num_actions occupies bits 11-15 (mask 0xF800) of the flags dword.
// Two actions -> num_actions = 2 -> bits = 2 << 11 = 0x1000.
// Each action emits u16 command then u16 (stamp & 0x7FFF) |
// (autonomous ? 0x8000 : 0) (decomp ~293998-294010).
var state = new RawMotionState
{
Actions = new[]
{
new RawMotionAction(Command: 0x0150, Stamp: 0x0001, Autonomous: false),
new RawMotionAction(Command: 0x0163, Stamp: 0x7FFF, Autonomous: true),
},
};
var body = Pack(state);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x1000u, flags); // num_actions=2 << 11, no continuous-axis bits
// Body: flags(4) + action0(4) + action1(4) = 12 bytes.
Assert.Equal(12, body.Length);
ushort cmd0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(4));
ushort stamp0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(6));
Assert.Equal((ushort)0x0150, cmd0);
Assert.Equal((ushort)0x0001, stamp0); // autonomous=false -> 0x8000 bit clear
ushort cmd1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(8));
ushort stamp1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(10));
Assert.Equal((ushort)0x0163, cmd1);
Assert.Equal((ushort)0xFFFF, stamp1); // (0x7FFF & 0x7FFF) | 0x8000 = 0xFFFF
}
[Fact]
public void Pack_SidestepAndTurnNonDefault_SetExpectedBitsInOrder()
{
// sidestep_command(0x020), sidestep_holdkey(0x040), sidestep_speed(0x080),
// turn_command(0x100), turn_holdkey(0x200), turn_speed(0x400) all non-default.
var state = new RawMotionState
{
SidestepCommand = 0x44000009u,
SidestepHoldKey = HoldKey.Run,
SidestepSpeed = 1.248f,
TurnCommand = 0x4400000Du,
TurnHoldKey = HoldKey.Run,
TurnSpeed = 1.5f,
};
var body = Pack(state);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x000007E0u, flags); // 0x20|0x40|0x80|0x100|0x200|0x400
// Body order after flags: sidestep_command, sidestep_holdkey,
// sidestep_speed, turn_command, turn_holdkey, turn_speed.
uint ssCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
uint ssHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
float ssSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12));
uint turnCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
uint turnHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20));
float turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
Assert.Equal(0x44000009u, ssCmd);
Assert.Equal(2u, ssHold);
Assert.Equal(1.248f, ssSpeed);
Assert.Equal(0x4400000Du, turnCmd);
Assert.Equal(2u, turnHold);
Assert.Equal(1.5f, turnSpeed);
Assert.Equal(28, body.Length);
}
}