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
166
src/AcDream.Core.Net/Packets/LoginRequest.cs
Normal file
166
src/AcDream.Core.Net/Packets/LoginRequest.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// The payload that ACE expects to find in the body of a packet whose
|
||||
/// header has <see cref="PacketHeaderFlags.LoginRequest"/> set. Acts as
|
||||
/// both a builder (for the outbound side) and a parser (for tests and
|
||||
/// diagnostics). Reimplemented from ACE's
|
||||
/// <c>PacketInboundLoginRequest.cs</c> — wire format is a fact about
|
||||
/// the retail client/server handshake.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (all little-endian):
|
||||
/// <code>
|
||||
/// 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
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LoginRequest
|
||||
{
|
||||
public enum NetAuthType : uint
|
||||
{
|
||||
Undefined = 0x00000000,
|
||||
Account = 0x00000001,
|
||||
AccountPassword = 0x00000002,
|
||||
GlsTicket = 0x40000002,
|
||||
}
|
||||
|
||||
/// <summary>Retail client version string that ACE expects in the ClientVersion field.</summary>
|
||||
public const string RetailClientVersion = "1802";
|
||||
|
||||
/// <summary>
|
||||
/// Build the login payload bytes for an account+password login. The
|
||||
/// returned buffer is what goes into the body of a packet with
|
||||
/// <see cref="PacketHeaderFlags.LoginRequest"/> set on the header.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static Parsed Parse(ReadOnlySpan<byte> 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<byte> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
162
src/AcDream.Core.Net/Packets/PacketWriter.cs
Normal file
162
src/AcDream.Core.Net/Packets/PacketWriter.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// Growable byte buffer with AC-specific write helpers. The wire format
|
||||
/// uses little-endian primitives plus two string encodings inherited from
|
||||
/// the retail client:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>String16L</b> — <c>u16 length</c> + ASCII bytes + zero
|
||||
/// padding to the next 4-byte boundary (counting from the length
|
||||
/// prefix).</item>
|
||||
/// <item><b>String32L</b> — used only in the login header. <c>u32 outer
|
||||
/// length</c> + 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 <c>0x00</c>. Strings longer
|
||||
/// than 255 chars need a two-byte marker; acdream asserts usernames
|
||||
/// and passwords stay short and avoids that case.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 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
|
||||
/// <c>System.Text.Encoding.CodePages</c> if we ever see non-ASCII identifiers.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Bytes written so far, as a freshly-allocated array.</summary>
|
||||
public byte[] ToArray()
|
||||
{
|
||||
var copy = new byte[_position];
|
||||
Array.Copy(_buffer, copy, _position);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public ReadOnlySpan<byte> 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<byte> bytes)
|
||||
{
|
||||
EnsureCapacity(bytes.Length);
|
||||
bytes.CopyTo(_buffer.AsSpan(_position));
|
||||
_position += bytes.Length;
|
||||
}
|
||||
|
||||
/// <summary>Pad with zeros so the buffer length is a multiple of 4.</summary>
|
||||
public void AlignTo4()
|
||||
{
|
||||
int padding = (4 - (_position & 3)) & 3;
|
||||
EnsureCapacity(padding);
|
||||
for (int i = 0; i < padding; i++) _buffer[_position++] = 0;
|
||||
}
|
||||
|
||||
/// <summary>Pad with N zero bytes from the current position.</summary>
|
||||
public void Pad(int count)
|
||||
{
|
||||
if (count <= 0) return;
|
||||
EnsureCapacity(count);
|
||||
for (int i = 0; i < count; i++) _buffer[_position++] = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a String16L: u16 length + ASCII bytes + pad-to-4 from the
|
||||
/// start of the length prefix.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="value"/> is ≤ 255 characters.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
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