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,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);
}
}

View file

@ -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<ArgumentException>(() => 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());
}
}