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:
+///
+///
+/// - String16L — u16 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());
+ }
+}