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:
parent
c64bbf29e4
commit
44c335469a
4 changed files with 556 additions and 0 deletions
108
tests/AcDream.Core.Net.Tests/Packets/LoginRequestTests.cs
Normal file
108
tests/AcDream.Core.Net.Tests/Packets/LoginRequestTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
tests/AcDream.Core.Net.Tests/Packets/PacketWriterTests.cs
Normal file
120
tests/AcDream.Core.Net.Tests/Packets/PacketWriterTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue