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:
parent
d9cd2b0b1d
commit
fe1c949775
6 changed files with 577 additions and 0 deletions
88
src/AcDream.Core.Net/Messages/AutonomousPosition.cs
Normal file
88
src/AcDream.Core.Net/Messages/AutonomousPosition.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
151
src/AcDream.Core.Net/Messages/MoveToState.cs
Normal file
151
src/AcDream.Core.Net/Messages/MoveToState.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
131
tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
Normal file
131
tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
172
tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
Normal file
172
tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue