acdream/src/AcDream.Core.Net/Packets/PacketWriter.cs
Erik fe1c949775 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>
2026-04-12 14:28:35 +02:00

169 lines
5.7 KiB
C#

using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Packets;
/// <summary>
/// Growable byte buffer with AC-specific write helpers. The wire format
/// uses little-endian primitives plus two string encodings inherited from
/// the retail client:
///
/// <list type="bullet">
/// <item><b>String16L</b> — <c>u16 length</c> + ASCII bytes + zero
/// padding to the next 4-byte boundary (counting from the length
/// prefix).</item>
/// <item><b>String32L</b> — used only in the login header. <c>u32 outer
/// length</c> + a single marker byte + ASCII bytes + optional
/// padding. Outer length = strlen + 1. The marker byte's value is
/// ignored by the reader so we emit <c>0x00</c>. Strings longer
/// than 255 chars need a two-byte marker; acdream asserts usernames
/// and passwords stay short and avoids that case.</item>
/// </list>
///
/// Uses Windows-1252 at the .NET level by falling back to ASCII bytes for
/// the characters we actually care about (usernames and passwords in
/// ACE-auto-created accounts are ASCII-safe). A future phase can fold in
/// <c>System.Text.Encoding.CodePages</c> if we ever see non-ASCII identifiers.
/// </summary>
public sealed class PacketWriter
{
private byte[] _buffer;
private int _position;
public PacketWriter(int initialCapacity = 256)
{
_buffer = new byte[initialCapacity];
_position = 0;
}
public int Position => _position;
/// <summary>Bytes written so far, as a freshly-allocated array.</summary>
public byte[] ToArray()
{
var copy = new byte[_position];
Array.Copy(_buffer, copy, _position);
return copy;
}
/// <summary>Bytes written so far, as a span view over the internal buffer.
/// Do not hold on to this after more writes — the buffer may be reallocated.</summary>
public ReadOnlySpan<byte> AsSpan() => _buffer.AsSpan(0, _position);
private void EnsureCapacity(int additional)
{
int required = _position + additional;
if (required <= _buffer.Length) return;
int newSize = _buffer.Length;
while (newSize < required) newSize *= 2;
var bigger = new byte[newSize];
Array.Copy(_buffer, bigger, _position);
_buffer = bigger;
}
public void WriteByte(byte value)
{
EnsureCapacity(1);
_buffer[_position++] = value;
}
public void WriteUInt16(ushort value)
{
EnsureCapacity(2);
BinaryPrimitives.WriteUInt16LittleEndian(_buffer.AsSpan(_position), value);
_position += 2;
}
public void WriteUInt32(uint value)
{
EnsureCapacity(4);
BinaryPrimitives.WriteUInt32LittleEndian(_buffer.AsSpan(_position), value);
_position += 4;
}
public void WriteBytes(ReadOnlySpan<byte> bytes)
{
EnsureCapacity(bytes.Length);
bytes.CopyTo(_buffer.AsSpan(_position));
_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()
{
int padding = (4 - (_position & 3)) & 3;
EnsureCapacity(padding);
for (int i = 0; i < padding; i++) _buffer[_position++] = 0;
}
/// <summary>Pad with N zero bytes from the current position.</summary>
public void Pad(int count)
{
if (count <= 0) return;
EnsureCapacity(count);
for (int i = 0; i < count; i++) _buffer[_position++] = 0;
}
/// <summary>
/// Write a String16L: u16 length + ASCII bytes + pad-to-4 from the
/// start of the length prefix.
/// </summary>
public void WriteString16L(string value)
{
value ??= string.Empty;
int asciiLen = value.Length;
WriteUInt16((ushort)asciiLen);
if (asciiLen > 0)
{
EnsureCapacity(asciiLen);
// Encoding.ASCII is sufficient for dev account names/passwords.
int written = Encoding.ASCII.GetBytes(value, 0, asciiLen, _buffer, _position);
_position += written;
}
// Pad from the start of the string record (u16 + asciiLen).
int recordSize = 2 + asciiLen;
int padding = (4 - (recordSize & 3)) & 3;
Pad(padding);
}
/// <summary>
/// Write a String32L: u32 outer length (= asciiLen + 1), one marker
/// byte (value 0, ignored by reader), asciiLen bytes, then pad to 4
/// from the start of the length prefix.
/// Asserts <paramref name="value"/> is ≤ 255 characters.
/// </summary>
public void WriteString32L(string value)
{
value ??= string.Empty;
if (value.Length > 255)
throw new ArgumentException($"String32L only supports short strings (≤255), got {value.Length}", nameof(value));
int asciiLen = value.Length;
if (asciiLen == 0)
{
// Reader treats length==0 as empty string with no marker, no content.
WriteUInt32(0);
return;
}
WriteUInt32((uint)(asciiLen + 1)); // outer length includes the marker
WriteByte(0); // marker byte — value is ignored
if (asciiLen > 0)
{
EnsureCapacity(asciiLen);
int written = Encoding.ASCII.GetBytes(value, 0, asciiLen, _buffer, _position);
_position += written;
}
// Pad from start of the u32 length prefix: 4 + 1 + asciiLen.
int recordSize = 4 + 1 + asciiLen;
int padding = (4 - (recordSize & 3)) & 3;
Pad(padding);
}
}