From 44c335469a27f8f71f166a7891a168185aeb59c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 14:36:39 +0200 Subject: [PATCH] feat(net): PacketWriter + LoginRequest payload builder (Phase 4.5a/b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core.Net/Packets/LoginRequest.cs | 166 ++++++++++++++++++ src/AcDream.Core.Net/Packets/PacketWriter.cs | 162 +++++++++++++++++ .../Packets/LoginRequestTests.cs | 108 ++++++++++++ .../Packets/PacketWriterTests.cs | 120 +++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 src/AcDream.Core.Net/Packets/LoginRequest.cs create mode 100644 src/AcDream.Core.Net/Packets/PacketWriter.cs create mode 100644 tests/AcDream.Core.Net.Tests/Packets/LoginRequestTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Packets/PacketWriterTests.cs diff --git a/src/AcDream.Core.Net/Packets/LoginRequest.cs b/src/AcDream.Core.Net/Packets/LoginRequest.cs new file mode 100644 index 0000000..7fdc609 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/LoginRequest.cs @@ -0,0 +1,166 @@ +using System.Buffers.Binary; +using System.Text; + +namespace AcDream.Core.Net.Packets; + +/// +/// The payload that ACE expects to find in the body of a packet whose +/// header has set. Acts as +/// both a builder (for the outbound side) and a parser (for tests and +/// diagnostics). Reimplemented from ACE's +/// PacketInboundLoginRequest.cs — wire format is a fact about +/// the retail client/server handshake. +/// +/// +/// Wire layout (all little-endian): +/// +/// 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 +/// +/// +/// +public static class LoginRequest +{ + public enum NetAuthType : uint + { + Undefined = 0x00000000, + Account = 0x00000001, + AccountPassword = 0x00000002, + GlsTicket = 0x40000002, + } + + /// Retail client version string that ACE expects in the ClientVersion field. + public const string RetailClientVersion = "1802"; + + /// + /// Build the login payload bytes for an account+password login. The + /// returned buffer is what goes into the body of a packet with + /// set on the header. + /// + 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; + } + + /// + /// 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. + /// + public static Parsed Parse(ReadOnlySpan 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 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 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; + } +} diff --git a/src/AcDream.Core.Net/Packets/PacketWriter.cs b/src/AcDream.Core.Net/Packets/PacketWriter.cs new file mode 100644 index 0000000..7c7c7d9 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/PacketWriter.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; +using System.Text; + +namespace AcDream.Core.Net.Packets; + +/// +/// Growable byte buffer with AC-specific write helpers. The wire format +/// uses little-endian primitives plus two string encodings inherited from +/// the retail client: +/// +/// +/// String16Lu16 length + ASCII bytes + zero +/// padding to the next 4-byte boundary (counting from the length +/// prefix). +/// String32L — used only in the login header. u32 outer +/// length + 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 0x00. Strings longer +/// than 255 chars need a two-byte marker; acdream asserts usernames +/// and passwords stay short and avoids that case. +/// +/// +/// 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 +/// System.Text.Encoding.CodePages if we ever see non-ASCII identifiers. +/// +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; + + /// Bytes written so far, as a freshly-allocated array. + public byte[] ToArray() + { + var copy = new byte[_position]; + Array.Copy(_buffer, copy, _position); + return copy; + } + + /// 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. + public ReadOnlySpan 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 bytes) + { + EnsureCapacity(bytes.Length); + bytes.CopyTo(_buffer.AsSpan(_position)); + _position += bytes.Length; + } + + /// Pad with zeros so the buffer length is a multiple of 4. + public void AlignTo4() + { + int padding = (4 - (_position & 3)) & 3; + EnsureCapacity(padding); + for (int i = 0; i < padding; i++) _buffer[_position++] = 0; + } + + /// Pad with N zero bytes from the current position. + public void Pad(int count) + { + if (count <= 0) return; + EnsureCapacity(count); + for (int i = 0; i < count; i++) _buffer[_position++] = 0; + } + + /// + /// Write a String16L: u16 length + ASCII bytes + pad-to-4 from the + /// start of the length prefix. + /// + 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); + } + + /// + /// 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 is ≤ 255 characters. + /// + 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); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/LoginRequestTests.cs b/tests/AcDream.Core.Net.Tests/Packets/LoginRequestTests.cs new file mode 100644 index 0000000..24e036c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/LoginRequestTests.cs @@ -0,0 +1,108 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests.Packets; + +public class LoginRequestTests +{ + [Fact] + public void Build_Then_Parse_RoundTripsAllFields() + { + var bytes = LoginRequest.Build( + account: "testaccount", + password: "testpassword", + timestamp: 0x12345678u); + + var parsed = LoginRequest.Parse(bytes); + + Assert.Equal(LoginRequest.RetailClientVersion, parsed.ClientVersion); + Assert.Equal(LoginRequest.NetAuthType.AccountPassword, parsed.NetAuth); + Assert.Equal(0u, parsed.AuthFlags); + Assert.Equal(0x12345678u, parsed.Timestamp); + Assert.Equal("testaccount", parsed.Account); + Assert.Equal(string.Empty, parsed.LoginAs); + Assert.Equal("testpassword", parsed.Password); + Assert.Null(parsed.GlsTicket); + } + + [Fact] + public void Build_Then_Parse_EmptyAccountAndPassword() + { + // The wire format still has to be valid even for empty strings — + // this catches off-by-one bugs in padding. + var bytes = LoginRequest.Build( + account: string.Empty, + password: string.Empty, + timestamp: 1); + + var parsed = LoginRequest.Parse(bytes); + + Assert.Equal(string.Empty, parsed.Account); + Assert.Equal(string.Empty, parsed.Password); + } + + [Fact] + public void Build_BodyLength_ReflectsActualRemainingBytes() + { + // The uint32 BodyLength field right after ClientVersion must equal + // the number of bytes remaining in the payload after the BodyLength + // field itself. ACE's parser uses this to bound its reads. + var bytes = LoginRequest.Build("u", "p", 0); + var parsed = LoginRequest.Parse(bytes); + + // Total = ClientVersion(String16L of "1802") + 4(bodyLength) + body + // ClientVersion "1802" = u16(4) + 4 bytes = 6 bytes, padded to 8. + // So bodyStart = 12. BodyLength field should equal total - 12. + int clientVersionAndLengthPrefix = 8 + 4; + int expectedBodyLength = bytes.Length - clientVersionAndLengthPrefix; + Assert.Equal((uint)expectedBodyLength, parsed.BodyLength); + } + + [Fact] + public void Build_TotalWireSizeIsMultipleOfFour() + { + // Each field pads to a 4-byte boundary, so the total should land on one. + var bytes = LoginRequest.Build("testaccount", "testpassword", 42); + Assert.Equal(0, bytes.Length % 4); + } + + [Fact] + public void Build_DifferentCredentials_ProduceDifferentBytes() + { + var a = LoginRequest.Build("alice", "alicepw", 1); + var b = LoginRequest.Build("bob", "bobpw", 1); + Assert.NotEqual(a, b); + } + + [Fact] + public void LoginRequest_EmbeddedInPacket_DecodableByPacketCodec() + { + // Full end-to-end: build a LoginRequest body, put it inside a packet + // with LoginRequest header flag set and the correct unencrypted + // checksum, run PacketCodec.TryDecode, verify the packet parses and + // BodyBytes contains our original payload. + var payload = LoginRequest.Build("testaccount", "testpassword", 1); + + var header = new PacketHeader + { + Flags = PacketHeaderFlags.LoginRequest, + DataSize = (ushort)payload.Length, + }; + uint headerHash = header.CalculateHeaderHash32(); + uint optionalHash = AcDream.Core.Net.Cryptography.Hash32.Calculate(payload); + header.Checksum = headerHash + optionalHash; + + byte[] datagram = new byte[PacketHeader.Size + payload.Length]; + header.Pack(datagram); + payload.CopyTo(datagram.AsSpan(PacketHeader.Size)); + + var result = PacketCodec.TryDecode(datagram, inboundIsaac: null); + Assert.Equal(PacketCodec.DecodeError.None, result.Error); + Assert.NotNull(result.Packet); + Assert.Equal(payload, result.Packet!.BodyBytes); + + // And the embedded payload parses back to the same credentials. + var parsed = LoginRequest.Parse(result.Packet.BodyBytes); + Assert.Equal("testaccount", parsed.Account); + Assert.Equal("testpassword", parsed.Password); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketWriterTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketWriterTests.cs new file mode 100644 index 0000000..ef044f4 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/PacketWriterTests.cs @@ -0,0 +1,120 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests.Packets; + +public class PacketWriterTests +{ + [Fact] + public void WriteUInt32_WritesLittleEndianBytes() + { + var w = new PacketWriter(); + w.WriteUInt32(0x04030201u); + Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0x04 }, w.ToArray()); + } + + [Fact] + public void WriteString16L_EmptyString_WritesOnlyLengthZeroAndPadding() + { + var w = new PacketWriter(); + w.WriteString16L(string.Empty); + + // u16(0) = 2 bytes, pad to 4 = 2 more bytes + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, w.ToArray()); + } + + [Fact] + public void WriteString16L_TwoCharString_PadsToMultipleOfFour() + { + var w = new PacketWriter(); + w.WriteString16L("ab"); + + // u16(2), 'a', 'b', 0 pad byte → total 5 bytes... wait that's not 4-aligned. + // Record = 2 (length prefix) + 2 (ascii) = 4. No padding needed. + Assert.Equal(new byte[] { 0x02, 0x00, (byte)'a', (byte)'b' }, w.ToArray()); + } + + [Fact] + public void WriteString16L_OneCharString_PadsToFour() + { + var w = new PacketWriter(); + w.WriteString16L("x"); + // Record = 2 + 1 = 3, pad 1 byte → 4 total + Assert.Equal(new byte[] { 0x01, 0x00, (byte)'x', 0x00 }, w.ToArray()); + } + + [Fact] + public void WriteString16L_ThreeCharString_PadsToEight() + { + var w = new PacketWriter(); + w.WriteString16L("abc"); + // Record = 2 + 3 = 5, pad 3 → 8 total + Assert.Equal(new byte[] { 0x03, 0x00, (byte)'a', (byte)'b', (byte)'c', 0, 0, 0 }, w.ToArray()); + } + + [Fact] + public void WriteString32L_ShortString_WritesU32OuterMarkerStringPadded() + { + var w = new PacketWriter(); + w.WriteString32L("hi"); + + // outer = 2 + 1 = 3, then marker byte 0, then 'h' 'i', pad from start + // of u32: record = 4 + 1 + 2 = 7, pad 1 → 8 total + Assert.Equal(new byte[] + { + 0x03, 0x00, 0x00, 0x00, // outer length = 3 + 0x00, // marker + (byte)'h', (byte)'i', + 0x00, // pad to 8 + }, w.ToArray()); + } + + [Fact] + public void WriteString32L_EmptyString_WritesOnlyZeroLength() + { + var w = new PacketWriter(); + w.WriteString32L(""); + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, w.ToArray()); + } + + [Fact] + public void WriteString32L_TooLong_Throws() + { + var w = new PacketWriter(); + Assert.Throws(() => w.WriteString32L(new string('x', 256))); + } + + [Fact] + public void AlignTo4_NoOpIfAlreadyAligned() + { + var w = new PacketWriter(); + w.WriteUInt32(0xCAFEu); + int before = w.Position; + w.AlignTo4(); + Assert.Equal(before, w.Position); + } + + [Fact] + public void AlignTo4_AddsPaddingWhenMisaligned() + { + var w = new PacketWriter(); + w.WriteByte(0xAA); + w.AlignTo4(); + Assert.Equal(4, w.Position); + var buf = w.ToArray(); + Assert.Equal(0xAA, buf[0]); + Assert.Equal(0x00, buf[1]); + Assert.Equal(0x00, buf[2]); + Assert.Equal(0x00, buf[3]); + } + + [Fact] + public void Grow_HandlesLargeWrites() + { + var w = new PacketWriter(16); // small initial + var big = new byte[500]; + for (int i = 0; i < big.Length; i++) big[i] = (byte)(i & 0xFF); + w.WriteBytes(big); + Assert.Equal(500, w.Position); + Assert.Equal(big, w.ToArray()); + } +}