feat(net): Phase B.2 — MoveToState + AutonomousPosition message builders

Outbound GameAction message builders for player movement:
- MoveToState (0xF61C): sent on motion state changes (start/stop
  walking, turn, speed change). Carries RawMotionState (flag-driven
  variable fields) + WorldPosition + sequence numbers.
- AutonomousPosition (0xF753): periodic position heartbeat sent
  every ~200ms while moving. No RawMotionState — just WorldPosition
  + sequences + contact byte.

Both follow the GameAction envelope pattern (0xF7B1 + sequence +
action type) established by GameActionLoginComplete. Wire format
ported from references/holtburger movement protocol — field order
and alignment match exactly (contact byte + pad_to_4).

Also:
- Adds WriteFloat to PacketWriter (needed by both builders)
- Adds SendGameAction + NextGameActionSequence to WorldSession
  (public wrappers for PlayerMovementController in Task 2)

11 new tests, 265 total, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 14:28:35 +02:00
parent d9cd2b0b1d
commit fe1c949775
6 changed files with 577 additions and 0 deletions

View file

@ -0,0 +1,88 @@
using System.Numerics;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound <c>GameAction(AutonomousPosition)</c> message — opcode
/// <c>0xF753</c>. Sent roughly every 200ms while the player is moving to
/// give the server a periodic ground-truth position heartbeat. Simpler than
/// <see cref="MoveToState"/>: no RawMotionState, just the GameAction
/// envelope, a WorldPosition, sequence numbers, a contact byte, and 4-byte
/// alignment padding.
///
/// <para>
/// Wire layout (ported from
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs</c>
/// <c>AutonomousPositionActionData::pack</c>):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF753</item>
/// <item><b>WorldPosition</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ</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>Align to 4 bytes</b></item>
/// </list>
/// </summary>
public static class AutonomousPosition
{
public const uint GameActionOpcode = 0xF7B1u;
public const uint AutonomousPositionAction = 0xF753u;
/// <summary>
/// Build an AutonomousPosition GameAction body.
/// </summary>
/// <param name="gameActionSequence">Monotonically increasing counter from
/// <see cref="WorldSession.NextGameActionSequence"/>.</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>
/// <param name="instanceSequence">Instance sequence number from the server.</param>
/// <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="lastContact">1 if the character was last on the ground, 0 if airborne.</param>
public static byte[] Build(
uint gameActionSequence,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
byte lastContact = 1)
{
var w = new PacketWriter(64);
// --- GameAction envelope ---
w.WriteUInt32(GameActionOpcode);
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(AutonomousPositionAction);
// --- WorldPosition (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);
// --- Sequence numbers ---
w.WriteUInt16(instanceSequence);
w.WriteUInt16(serverControlSequence);
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
// --- Contact byte + 4-byte align ---
w.WriteByte(lastContact);
w.AlignTo4();
return w.ToArray();
}
}

View file

@ -0,0 +1,151 @@
using System.Numerics;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound <c>GameAction(MoveToState)</c> message — opcode <c>0xF61C</c>.
/// Sent whenever the client's motion state changes: starting or stopping
/// walking, switching direction, changing speed, entering or leaving combat
/// stance. The server uses this to update the player's authoritative motion
/// 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>):
/// </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>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>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="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>
/// <param name="instanceSequence">Instance sequence number from the server.</param>
/// <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>
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)
{
var w = new PacketWriter(128);
// --- GameAction envelope ---
w.WriteUInt32(GameActionOpcode);
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(MoveToStateAction);
// --- RawMotionState ---
// Build the flags word. Command list length (bits 11-31) is always 0.
uint flags = 0u;
if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
if (forwardCommand.HasValue) flags |= FlagForwardCommand;
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
if (turnCommand.HasValue) flags |= FlagTurnCommand;
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
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);
// FlagForwardHoldKey (0x8): not sent
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
// FlagSidestepHoldKey (0x40): not sent
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
// FlagTurnHoldKey (0x200): not sent
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
// --- WorldPosition (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);
// --- Sequence numbers ---
w.WriteUInt16(instanceSequence);
w.WriteUInt16(serverControlSequence);
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
// --- Contact byte + 4-byte align ---
w.WriteByte(contactLongJump);
w.AlignTo4();
return w.ToArray();
}
}

View file

@ -88,6 +88,13 @@ public sealed class PacketWriter
_position += bytes.Length;
}
public void WriteFloat(float value)
{
EnsureCapacity(4);
BinaryPrimitives.WriteSingleLittleEndian(_buffer.AsSpan(_position), value);
_position += 4;
}
/// <summary>Pad with zeros so the buffer length is a multiple of 4.</summary>
public void AlignTo4()
{

View file

@ -126,6 +126,16 @@ public sealed class WorldSession : IDisposable
/// </summary>
private bool _loginCompleteSent;
/// <summary>
/// Phase B.2: per-session game-action sequence counter. Monotonically
/// incremented by <see cref="NextGameActionSequence"/> and embedded in
/// every outbound MoveToState / AutonomousPosition GameAction message.
/// ACE's GameActionPacket.HandleGameAction reads the sequence field but
/// currently only uses it for logging — however retail clients do
/// increment it, so we match that behaviour.
/// </summary>
private uint _gameActionSequence;
public WorldSession(IPEndPoint serverLogin)
{
_loginEndpoint = serverLogin;
@ -419,6 +429,24 @@ public sealed class WorldSession : IDisposable
}
}
/// <summary>
/// Phase B.2: send a pre-built GameAction body (which already contains
/// the 0xF7B1 envelope + sequence + action-type header). Used by the
/// PlayerMovementController for MoveToState and AutonomousPosition.
/// </summary>
public void SendGameAction(byte[] gameActionBody)
{
SendGameMessage(gameActionBody);
}
/// <summary>
/// Phase B.2: get and increment the game-action sequence counter.
/// Call once per outbound movement message; pass the returned value
/// to <see cref="Messages.MoveToState.Build"/> or
/// <see cref="Messages.AutonomousPosition.Build"/>.
/// </summary>
public uint NextGameActionSequence() => ++_gameActionSequence;
private void SendGameMessage(byte[] gameMessageBody)
{
var fragment = GameMessageFragment.BuildSingleFragment(

View file

@ -0,0 +1,131 @@
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public class AutonomousPositionTests
{
[Fact]
public void Build_ProducesValidGameAction()
{
var body = AutonomousPosition.Build(
gameActionSequence: 5,
cellId: 0xA9B40001u,
position: new Vector3(100f, 100f, 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(5u, seq);
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF753u, actionType);
}
[Fact]
public void Build_ContainsCellIdAfterHeader()
{
var body = AutonomousPosition.Build(
gameActionSequence: 1,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// After the 12-byte GameAction header, the WorldPosition starts
// with u32 cell_id.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0xDEADBEEFu, cellId);
}
[Fact]
public void Build_ContainsPosition_AfterCellId()
{
var body = AutonomousPosition.Build(
gameActionSequence: 2,
cellId: 0xA9B40001u,
position: new Vector3(12.5f, 34.0f, 56.75f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// WorldPosition: cellId (4) + x (4) + y (4) + z (4) at offsets 12-27
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16));
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20));
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
Assert.Equal(12.5f, x);
Assert.Equal(34.0f, y);
Assert.Equal(56.75f, z);
}
[Fact]
public void Build_IsAlignedTo4Bytes()
{
var body = AutonomousPosition.Build(
gameActionSequence: 3,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(0, body.Length % 4);
}
[Fact]
public void Build_TotalLengthIsCorrect_NoCommandsNoExtraFields()
{
// 12 (envelope) + 32 (WorldPosition) + 8 (4x u16 sequences) + 1 (contact) + 3 (align) = 56
var body = AutonomousPosition.Build(
gameActionSequence: 4,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(56, body.Length);
}
[Fact]
public void Build_ContainsIdentityRotation_AfterPosition()
{
var body = AutonomousPosition.Build(
gameActionSequence: 6,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity, // W=1, X=Y=Z=0
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// Rotation starts at offset 28: rotW(4), rotX(4), rotY(4), rotZ(4)
float rotW = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(28));
float rotX = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(32));
float rotY = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(36));
float rotZ = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(40));
Assert.Equal(1.0f, rotW);
Assert.Equal(0.0f, rotX);
Assert.Equal(0.0f, rotY);
Assert.Equal(0.0f, rotZ);
}
}

View file

@ -0,0 +1,172 @@
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public class MoveToStateTests
{
[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,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// First 4 bytes: GameAction opcode 0xF7B1
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0xF7B1u, opcode);
// Bytes 4-7: game action sequence
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(1u, seq);
// Bytes 8-11: MoveToState action type 0xF61C
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF61Cu, actionType);
}
[Fact]
public void Build_WalkForward_IncludesForwardCommandInFlags()
{
var body = MoveToState.Build(
gameActionSequence: 2,
forwardCommand: 0x45000005u, // WalkForward
forwardSpeed: 1.0f,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
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");
}
[Fact]
public void Build_IdleState_RawMotionFlagsAreZero()
{
var body = MoveToState.Build(
gameActionSequence: 3,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0u, flags);
}
[Fact]
public void Build_IdleState_WorldPositionFollowsMotionState()
{
// With no 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,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
Assert.Equal(0xDEADBEEFu, cellId);
}
[Fact]
public void Build_IsAlignedTo4Bytes()
{
var body = MoveToState.Build(
gameActionSequence: 5,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(0, body.Length % 4);
}
[Fact]
public void Build_WithHoldKey_IncludesHoldKeyFlag()
{
var body = MoveToState.Build(
gameActionSequence: 6,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: 2u, // Run
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.True((flags & 0x1u) != 0, "CurrentHoldKey flag (0x1) should be set");
// The hold key value (u32 = 2) should immediately follow the flags
uint holdKeyValue = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
Assert.Equal(2u, holdKeyValue);
}
}