Brings the codec to end-to-end: a raw UDP datagram goes in, a parsed
Packet comes out with verified CRC (both plain and ISAAC-encrypted
variants). Synthetic packets built inside tests round-trip through
TryDecode cleanly.
Added:
- Packets/PacketHeaderOptional.cs: parses every flag-gated section
that lives between the 20-byte header and the body fragments —
AckSequence, RequestRetransmit (with count + array), RejectRetransmit,
ServerSwitch, LoginRequest (tail slurp), WorldLoginRequest,
ConnectResponse, CICMDCommand, TimeSync (double), EchoRequest (float),
Flow (FlowBytes + FlowInterval). Records the raw consumed bytes into
RawBytes so CalculateHash32 can hash them verbatim — AC's CRC requires
hashing the optional section separately from the main header and the
fragments.
- Packets/Packet.cs: a record type bundling Header, Optional, Fragments,
and the raw body bytes. Produced by the decoder, consumed by downstream
handlers in Phase 4.5.
- Packets/PacketCodec.cs: TryDecode(datagram, isaac?) that
1. Unpacks the header,
2. Bounds-checks DataSize against the buffer,
3. Parses the optional section,
4. If BlobFragments is set, walks the body tail as back-to-back
MessageFragment.TryParse calls,
5. Computes headerHash + optionalHash + fragmentHash,
6. Verifies CRC:
- Unencrypted: sum equals header.Checksum
- Encrypted: (header.Checksum - headerHash) XOR payloadHash must
equal the next ISAAC keystream word (which is consumed on match)
Returns a PacketDecodeResult(Packet?, DecodeError) so callers can log
and drop malformed packets instead of throwing.
- Public helper PacketCodec.CalculateFragmentHash32 so tests (and later
the encode path) can reuse the fragment-hash math.
Tests (7 new, 44 total in net project, 121 across both test projects):
- Minimal valid packet with AckSequence optional, no fragments, plain
checksum — verifies optional parse + CRC accept
- Wrong checksum rejected
- Buffer shorter than header → TooShort
- Header DataSize > buffer → HeaderSizeExceedsBuffer
- Packet with BlobFragments flag + one fragment: parses fragment and
validates the full headerHash + fragmentHash equals wire checksum
- Encrypted checksum ROUND TRIP: two ISAAC instances with same seed,
one encodes the checksum key, one decodes — validates the
(Header.Checksum - headerHash) XOR payloadHash == isaacNext contract
byte-for-byte
- Encrypted checksum with wrong key on the wire → rejected
Known limitation: the parser advances past WorldLoginRequest and
ConnectResponse their full 8 bytes whereas ACE "peeks" them (seek/reset).
The on-wire byte count is the same, only the read-position behavior
differs; any consumer that wanted to re-read those sections can do so
from Packet.BodyBytes.
Phase 4.5 (NetClient UDP pump + handshake state machine) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
6.5 KiB
C#
171 lines
6.5 KiB
C#
using AcDream.Core.Net.Cryptography;
|
|
using AcDream.Core.Net.Packets;
|
|
|
|
namespace AcDream.Core.Net.Tests.Packets;
|
|
|
|
public class PacketCodecTests
|
|
{
|
|
/// <summary>
|
|
/// Build a complete packet byte array by hand, compute its correct
|
|
/// unencrypted CRC, and verify TryDecode parses it and accepts the CRC.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryDecode_MinimalValidPacket_UnencryptedChecksum_Accepted()
|
|
{
|
|
// Body = one optional section (AckSequence, 4 bytes) + zero fragments.
|
|
// ACK sequence in optional body = 0x12345678.
|
|
byte[] body = new byte[4];
|
|
body[0] = 0x78; body[1] = 0x56; body[2] = 0x34; body[3] = 0x12;
|
|
|
|
var header = new PacketHeader
|
|
{
|
|
Sequence = 1,
|
|
Flags = PacketHeaderFlags.AckSequence,
|
|
Id = 42,
|
|
Time = 100,
|
|
DataSize = (ushort)body.Length,
|
|
Iteration = 0,
|
|
};
|
|
|
|
// Compute the unencrypted checksum: headerHash + optionalHash.
|
|
// No fragments → fragmentHash = 0.
|
|
uint headerHash = header.CalculateHeaderHash32();
|
|
uint optionalHash = Hash32.Calculate(body);
|
|
header.Checksum = headerHash + optionalHash;
|
|
|
|
// Assemble the datagram.
|
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
|
header.Pack(datagram);
|
|
body.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(0x12345678u, result.Packet!.Optional.AckSequence);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDecode_WrongChecksum_Rejected()
|
|
{
|
|
byte[] body = new byte[4] { 1, 2, 3, 4 };
|
|
var header = new PacketHeader
|
|
{
|
|
Flags = PacketHeaderFlags.AckSequence,
|
|
DataSize = (ushort)body.Length,
|
|
Checksum = 0xDEADBEEFu, // garbage checksum
|
|
};
|
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
|
header.Pack(datagram);
|
|
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
|
|
|
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
|
Assert.Equal(PacketCodec.DecodeError.ChecksumMismatch, result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDecode_BufferShorterThanHeader_ReturnsTooShort()
|
|
{
|
|
var result = PacketCodec.TryDecode(new byte[19], inboundIsaac: null);
|
|
Assert.Equal(PacketCodec.DecodeError.TooShort, result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDecode_DataSizeExceedsBuffer_Rejected()
|
|
{
|
|
var header = new PacketHeader { DataSize = 1000 };
|
|
byte[] datagram = new byte[PacketHeader.Size + 10]; // only 10 body bytes available
|
|
header.Pack(datagram);
|
|
|
|
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
|
Assert.Equal(PacketCodec.DecodeError.HeaderSizeExceedsBuffer, result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDecode_PacketWithOneFragment_ParsesAndValidatesCRC()
|
|
{
|
|
// Body: no optional section + one 16+3 = 19-byte fragment (BlobFragments flag).
|
|
var fragHeader = new MessageFragmentHeader
|
|
{
|
|
Sequence = 1, Id = 0x80000001u, Count = 1,
|
|
TotalSize = 19, Index = 0, Queue = 5,
|
|
};
|
|
byte[] fragBytes = new byte[19];
|
|
fragHeader.Pack(fragBytes);
|
|
fragBytes[16] = 0xAA; fragBytes[17] = 0xBB; fragBytes[18] = 0xCC;
|
|
|
|
var header = new PacketHeader
|
|
{
|
|
Flags = PacketHeaderFlags.BlobFragments,
|
|
DataSize = (ushort)fragBytes.Length,
|
|
};
|
|
|
|
// Compute unencrypted checksum.
|
|
uint headerHash = header.CalculateHeaderHash32();
|
|
uint optionalHash = 0; // empty optional section
|
|
var frag = new MessageFragment(fragHeader, new byte[] { 0xAA, 0xBB, 0xCC });
|
|
uint fragmentHash = PacketCodec.CalculateFragmentHash32(frag);
|
|
header.Checksum = headerHash + optionalHash + fragmentHash;
|
|
|
|
byte[] datagram = new byte[PacketHeader.Size + fragBytes.Length];
|
|
header.Pack(datagram);
|
|
fragBytes.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
|
|
|
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
|
|
|
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
|
Assert.Single(result.Packet!.Fragments);
|
|
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Packet.Fragments[0].Payload);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDecode_EncryptedChecksum_AcceptsMatchingIsaacKey()
|
|
{
|
|
// The ISAAC-encrypted form: the wire Checksum should equal
|
|
// headerHash + (isaacNext XOR payloadHash)
|
|
// so that VerifyCRC recovers isaacNext as the key and consumes it.
|
|
var body = new byte[4] { 1, 2, 3, 4 };
|
|
var header = new PacketHeader
|
|
{
|
|
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
|
|
DataSize = (ushort)body.Length,
|
|
};
|
|
|
|
// Build the same isaac in two places: one to compute the expected
|
|
// checksum, one to pass to TryDecode. Both use seed 0x11223344.
|
|
var seed = new byte[] { 0x44, 0x33, 0x22, 0x11 };
|
|
var isaacForEncode = new IsaacRandom(seed);
|
|
var isaacForDecode = new IsaacRandom(seed);
|
|
|
|
uint headerHash = header.CalculateHeaderHash32();
|
|
uint payloadHash = Hash32.Calculate(body); // optional hash; no fragments
|
|
uint isaacKey = isaacForEncode.Next();
|
|
header.Checksum = headerHash + (isaacKey ^ payloadHash);
|
|
|
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
|
header.Pack(datagram);
|
|
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
|
|
|
var result = PacketCodec.TryDecode(datagram, isaacForDecode);
|
|
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDecode_EncryptedChecksum_WrongIsaacKey_Rejected()
|
|
{
|
|
var body = new byte[4] { 1, 2, 3, 4 };
|
|
var header = new PacketHeader
|
|
{
|
|
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
|
|
DataSize = (ushort)body.Length,
|
|
Checksum = 0xDEADBEEFu, // wrong
|
|
};
|
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
|
header.Pack(datagram);
|
|
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
|
|
|
// Decoder gets a fresh ISAAC; its first Next() definitely won't match 0xDEADBEEF.
|
|
var result = PacketCodec.TryDecode(datagram, new IsaacRandom(new byte[] { 1, 2, 3, 4 }));
|
|
Assert.Equal(PacketCodec.DecodeError.ChecksumMismatch, result.Error);
|
|
}
|
|
}
|