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>
108 lines
4.1 KiB
C#
108 lines
4.1 KiB
C#
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);
|
|
}
|
|
}
|