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(