feat(net): PacketWriter + LoginRequest payload builder (Phase 4.5a/b)

Adds the outbound-side primitives acdream needs to send a LoginRequest
packet to an ACE server: a growable byte buffer writer with AC's two
length-prefixed string formats, plus the LoginRequest payload builder
and parser.

PacketWriter (Packets/PacketWriter.cs):
  - Growable byte[] buffer with little-endian WriteByte/UInt16/UInt32/Bytes
  - WriteString16L: u16 length + ASCII bytes + zero-pad to 4-byte boundary
    (pad counted from start of length prefix, matching ACE's Extensions.cs)
  - WriteString32L: u32 outer length (= asciiLen+1) + 1 marker byte (value
    ignored by reader, we emit 0) + ASCII + pad. Reader decrements the
    outer length by 1 when consuming the marker, so asciiLen is recovered
    correctly. Asserts ≤255 chars (two-byte-marker variant not needed for
    acdream's dev credentials).
  - ASCII encoding used instead of Windows-1252 since dev account names
    and passwords are ASCII-safe; can switch to CodePages later if a
    non-ASCII identifier ever turns up.

LoginRequest (Packets/LoginRequest.cs):
  - Build(account, password, timestamp, clientVersion="1802") produces
    the login payload bytes that go into the body of a packet whose
    header has the LoginRequest flag set
  - Parse(bytes) for tests and diagnostics — server never calls this
    in production, but round-trip tests make the writer self-verifying
  - NetAuthType enum mirrors ACE: Account/AccountPassword/GlsTicket
  - Wire layout per ACE's PacketInboundLoginRequest:
      String16L ClientVersion
      u32       bodyLength (bytes remaining after this field)
      u32       NetAuthType (2 = AccountPassword)
      u32       AuthFlags (0 for normal client)
      u32       Timestamp
      String16L Account
      String16L LoginAs (empty for non-admin)
      String32L Password (when AccountPassword)
  - bodyLength field is back-patched after the full body has been
    written (classic "write placeholder, come back and patch" flow)

Tests (17 new, 61 total in net project, 138 across both test projects):
  PacketWriter (11):
    - u32 little-endian
    - String16L: empty, 1/2/3-char with correct padding
    - String32L: 2-char short, empty, >255 throws
    - AlignTo4 no-op when aligned, pads when not
    - Buffer grows past initial capacity on big writes
  LoginRequest (6):
    - Build→Parse round-trip with realistic credentials (testaccount/
      testpassword/timestamp)
    - Empty account/password round-trip (padding edge case)
    - BodyLength field reflects actual remaining bytes after itself
    - Total wire size is multiple of 4 (sanity check on padding)
    - Different credentials produce different bytes
    - End-to-end: payload embedded in a full Packet with LoginRequest
      header flag + correct unencrypted checksum, PacketCodec.TryDecode
      parses it, BodyBytes round-trips back to the same credentials
      through LoginRequest.Parse

This gives acdream everything needed to construct the first datagram
of the handshake. Phase 4.5c next: WorldSession state machine to drive
the handshake sequence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:36:39 +02:00
parent c64bbf29e4
commit 44c335469a
4 changed files with 556 additions and 0 deletions

View file

@ -0,0 +1,166 @@
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Packets;
/// <summary>
/// The payload that ACE expects to find in the body of a packet whose
/// header has <see cref="PacketHeaderFlags.LoginRequest"/> set. Acts as
/// both a builder (for the outbound side) and a parser (for tests and
/// diagnostics). Reimplemented from ACE's
/// <c>PacketInboundLoginRequest.cs</c> — wire format is a fact about
/// the retail client/server handshake.
///
/// <para>
/// Wire layout (all little-endian):
/// <code>
/// String16L ClientVersion ("1802" for retail)
/// u32 bodyLength bytes remaining in packet after this field
/// u32 NetAuthType 1=Account, 2=AccountPassword, 0x40000002=GlsTicket
/// u32 AuthFlags bitmask, 0 for normal client logins
/// u32 Timestamp client-picked sequence
/// String16L Account username
/// String16L LoginAs "" for normal (admin-only feature)
/// If NetAuthType == AccountPassword:
/// String32L Password
/// Else if NetAuthType == GlsTicket:
/// String32L GlsTicket
/// </code>
/// </para>
/// </summary>
public static class LoginRequest
{
public enum NetAuthType : uint
{
Undefined = 0x00000000,
Account = 0x00000001,
AccountPassword = 0x00000002,
GlsTicket = 0x40000002,
}
/// <summary>Retail client version string that ACE expects in the ClientVersion field.</summary>
public const string RetailClientVersion = "1802";
/// <summary>
/// Build the login payload bytes for an account+password login. The
/// returned buffer is what goes into the body of a packet with
/// <see cref="PacketHeaderFlags.LoginRequest"/> set on the header.
/// </summary>
public static byte[] Build(string account, string password, uint timestamp,
string clientVersion = RetailClientVersion)
{
ArgumentNullException.ThrowIfNull(account);
ArgumentNullException.ThrowIfNull(password);
// First pass: write header fields up to and including the bodyLength
// placeholder, remember its offset, then write the rest. Come back
// and patch bodyLength at the end.
var w = new PacketWriter(128);
w.WriteString16L(clientVersion);
int bodyLengthOffset = w.Position;
w.WriteUInt32(0); // placeholder, patched below
int bodyStart = w.Position;
w.WriteUInt32((uint)NetAuthType.AccountPassword);
w.WriteUInt32(0); // AuthFlags
w.WriteUInt32(timestamp);
w.WriteString16L(account);
w.WriteString16L(string.Empty); // LoginAs (empty for non-admin)
w.WriteString32L(password);
// Patch bodyLength: bytes written after the u32 length itself.
uint bodyLength = (uint)(w.Position - bodyStart);
var buf = w.ToArray();
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(bodyLengthOffset), bodyLength);
return buf;
}
/// <summary>
/// Parse a login payload. Used by tests to verify our builder's output
/// round-trips correctly. Not used on the acdream client's hot path —
/// it's the server's job to decode LoginRequest.
/// </summary>
public static Parsed Parse(ReadOnlySpan<byte> bytes)
{
int pos = 0;
string clientVersion = ReadString16L(bytes, ref pos);
if (bytes.Length - pos < 4) throw new FormatException("truncated before bodyLength");
uint bodyLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(pos));
pos += 4;
if (bytes.Length - pos < 4) throw new FormatException("truncated before NetAuthType");
var netAuth = (NetAuthType)BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(pos));
pos += 4;
uint authFlags = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(pos));
pos += 4;
uint timestamp = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(pos));
pos += 4;
string account = ReadString16L(bytes, ref pos);
string loginAs = ReadString16L(bytes, ref pos);
string? password = null;
string? glsTicket = null;
if (netAuth == NetAuthType.AccountPassword)
password = ReadString32L(bytes, ref pos);
else if (netAuth == NetAuthType.GlsTicket)
glsTicket = ReadString32L(bytes, ref pos);
return new Parsed(clientVersion, bodyLength, netAuth, authFlags, timestamp, account, loginAs, password, glsTicket);
}
public readonly record struct Parsed(
string ClientVersion,
uint BodyLength,
NetAuthType NetAuth,
uint AuthFlags,
uint Timestamp,
string Account,
string LoginAs,
string? Password,
string? GlsTicket);
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
pos += 2;
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
string result = Encoding.ASCII.GetString(source.Slice(pos, length));
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
private static string ReadString32L(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated String32L length");
uint outer = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
pos += 4;
if (outer == 0) return string.Empty;
// Marker byte (ignored, counted in outer length).
if (source.Length - pos < 1) throw new FormatException("truncated String32L marker");
pos += 1;
uint strLen = outer - 1;
if (strLen > 255)
{
// Two-byte marker variant — consume one more marker.
pos += 1;
strLen -= 1;
}
if (source.Length - pos < strLen) throw new FormatException("truncated String32L body");
string result = Encoding.ASCII.GetString(source.Slice(pos, (int)strLen));
pos += (int)strLen;
// Pad from start of u32 + marker(s) + strLen.
int recordSize = 4 + (int)(outer);
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
}

View file

@ -0,0 +1,162 @@
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;
}
/// <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);
}
}