diff --git a/src/AcDream.Core.Net/Packets/Packet.cs b/src/AcDream.Core.Net/Packets/Packet.cs
new file mode 100644
index 0000000..60da44c
--- /dev/null
+++ b/src/AcDream.Core.Net/Packets/Packet.cs
@@ -0,0 +1,28 @@
+namespace AcDream.Core.Net.Packets;
+
+///
+/// A fully-parsed AC UDP packet: 20-byte ,
+/// variable-length section, zero or more
+/// , and the raw body bytes following the header
+/// (for callers that want to re-read parts of the login/connect payload
+/// without re-invoking the parser).
+///
+///
+/// Produced by on the receive path;
+/// passed to on the transmit path after
+/// callers fill in the fields they want.
+///
+///
+public sealed class Packet
+{
+ public PacketHeader Header;
+ public PacketHeaderOptional Optional { get; } = new();
+ public List Fragments { get; } = new();
+
+ ///
+ /// Copy of the body bytes (everything after the 20-byte header). Useful
+ /// for LoginRequest packets where the login payload is the body tail and
+ /// the caller wants to extract username/password bytes directly.
+ ///
+ public byte[] BodyBytes { get; set; } = Array.Empty();
+}
diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs
new file mode 100644
index 0000000..615c729
--- /dev/null
+++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs
@@ -0,0 +1,129 @@
+using AcDream.Core.Net.Cryptography;
+
+namespace AcDream.Core.Net.Packets;
+
+///
+/// Decodes (and eventually encodes) AC UDP packets. Stateless decoder:
+/// callers feed it raw UDP datagram bytes + an optional ISAAC keystream
+/// for the encrypted-checksum path. Produces a on
+/// success or a error for the caller
+/// to log and drop.
+///
+///
+/// CRC verification algorithm (ported from ACE ClientPacket.VerifyCRC):
+///
+/// headerHash = Hash32(header bytes with Checksum field overwritten by 0xBADD70DD)
+/// optionalHash = Hash32(raw bytes of the optional header section)
+/// fragmentHash = sum of each fragment's (Hash32(fragHeader) + Hash32(fragPayload))
+///
+/// If EncryptedChecksum flag is set:
+/// key = (header.Checksum - headerHash) XOR (optionalHash + fragmentHash)
+/// key must equal the next ISAAC keystream word (consumed on success)
+/// Else:
+/// header.Checksum must equal (headerHash + optionalHash + fragmentHash)
+///
+///
+///
+public static class PacketCodec
+{
+ public enum DecodeError
+ {
+ None = 0,
+ TooShort, // buffer shorter than 20-byte header
+ HeaderSizeExceedsBuffer, // header.DataSize + 20 > buffer.Length
+ InvalidOptionalHeader,
+ InvalidFragment,
+ ChecksumMismatch,
+ }
+
+ public readonly record struct PacketDecodeResult(Packet? Packet, DecodeError Error)
+ {
+ public bool IsOk => Error == DecodeError.None;
+ }
+
+ ///
+ /// Parse a UDP datagram into a and verify its CRC.
+ ///
+ /// The raw UDP payload.
+ ///
+ /// ISAAC keystream for checking encrypted checksums on inbound packets,
+ /// or null if the packet's checksum will not be encrypted (any
+ /// packet without the EncryptedChecksum flag is verified additively).
+ ///
+ public static PacketDecodeResult TryDecode(ReadOnlySpan datagram, IsaacRandom? inboundIsaac)
+ {
+ if (datagram.Length < PacketHeader.Size)
+ return new PacketDecodeResult(null, DecodeError.TooShort);
+
+ var packet = new Packet { Header = PacketHeader.Unpack(datagram) };
+ int bodyLen = packet.Header.DataSize;
+ if (datagram.Length - PacketHeader.Size < bodyLen)
+ return new PacketDecodeResult(null, DecodeError.HeaderSizeExceedsBuffer);
+
+ var body = datagram.Slice(PacketHeader.Size, bodyLen);
+ packet.BodyBytes = body.ToArray();
+
+ // Parse the optional header section first.
+ int optionalConsumed = packet.Optional.Parse(body, packet.Header.Flags);
+ if (optionalConsumed < 0)
+ return new PacketDecodeResult(null, DecodeError.InvalidOptionalHeader);
+
+ // If the BlobFragments flag is set, walk the body tail as a sequence
+ // of fragments. LoginRequest packets don't have fragments (they have
+ // the login payload in the optional section instead).
+ if (packet.Header.HasFlag(PacketHeaderFlags.BlobFragments))
+ {
+ var fragSlice = body.Slice(optionalConsumed);
+ while (fragSlice.Length > 0)
+ {
+ var (frag, consumed) = MessageFragment.TryParse(fragSlice);
+ if (frag is null || consumed == 0)
+ return new PacketDecodeResult(null, DecodeError.InvalidFragment);
+ packet.Fragments.Add(frag.Value);
+ fragSlice = fragSlice.Slice(consumed);
+ }
+ }
+
+ // Compute the three checksum summands.
+ uint headerHash = packet.Header.CalculateHeaderHash32();
+ uint optionalHash = packet.Optional.CalculateHash32();
+ uint fragmentHash = 0;
+ foreach (var frag in packet.Fragments)
+ fragmentHash += CalculateFragmentHash32(frag);
+
+ uint payloadHash = optionalHash + fragmentHash;
+
+ if (packet.Header.HasFlag(PacketHeaderFlags.EncryptedChecksum))
+ {
+ if (inboundIsaac is null)
+ return new PacketDecodeResult(null, DecodeError.ChecksumMismatch);
+
+ // Expected key = (wireChecksum - headerHash) XOR payloadHash.
+ // That key must equal the next ISAAC keystream word.
+ uint expectedKey = (packet.Header.Checksum - headerHash) ^ payloadHash;
+ uint isaacKey = inboundIsaac.Next();
+ if (expectedKey != isaacKey)
+ return new PacketDecodeResult(null, DecodeError.ChecksumMismatch);
+ }
+ else
+ {
+ uint expected = headerHash + payloadHash;
+ if (packet.Header.Checksum != expected)
+ return new PacketDecodeResult(null, DecodeError.ChecksumMismatch);
+ }
+
+ return new PacketDecodeResult(packet, DecodeError.None);
+ }
+
+ ///
+ /// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload).
+ /// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers
+ /// (including tests) can reuse it when crafting synthetic packets.
+ ///
+ public static uint CalculateFragmentHash32(in MessageFragment frag)
+ {
+ Span headerBuf = stackalloc byte[MessageFragmentHeader.Size];
+ frag.Header.Pack(headerBuf);
+ return Hash32.Calculate(headerBuf) + Hash32.Calculate(frag.Payload);
+ }
+}
diff --git a/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs b/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
new file mode 100644
index 0000000..8ce7953
--- /dev/null
+++ b/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
@@ -0,0 +1,171 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Packets;
+
+///
+/// The variable-sized "optional header" section that lives between the
+/// 20-byte and the body fragments (or login
+/// payload) of an AC UDP packet. Each sub-section is gated by a flag in
+/// header.Flags — no flags, no optional section.
+///
+///
+/// Fields that actually exist once parsed (all default-zeroed when the
+/// corresponding flag isn't set):
+///
+///
+/// - — present when is set.
+/// - — list of sequence numbers to
+/// retransmit, present when is set.
+/// - — server time sync value, present when
+/// is set.
+/// - — present on echo requests.
+/// - / — flow control, present on Flow packets.
+///
+///
+///
+/// The parser also records the raw bytes it consumed in
+/// — AC's CRC verification requires hashing this
+/// optional section separately from the main header and the fragments, so
+/// the byte slice is needed downstream.
+///
+///
+/// Reimplemented from ACE's AGPL reference; see NOTICE.md.
+///
+public sealed class PacketHeaderOptional
+{
+ /// Raw bytes consumed from the wire, used by .
+ public byte[] RawBytes { get; private set; } = Array.Empty();
+
+ public uint AckSequence { get; private set; }
+ public IReadOnlyList RetransmitRequests { get; private set; } = Array.Empty();
+ public double TimeSync { get; private set; }
+ public float EchoRequestClientTime { get; private set; }
+ public uint FlowBytes { get; private set; }
+ public ushort FlowInterval { get; private set; }
+
+ ///
+ /// Parse the optional section from (which starts
+ /// right after the 20-byte header). Returns the number of bytes consumed
+ /// from , or -1 if the section is malformed
+ /// (short reads, impossible lengths). On success,
+ /// holds a copy of the consumed slice.
+ ///
+ public int Parse(ReadOnlySpan body, PacketHeaderFlags flags)
+ {
+ int pos = 0;
+
+ // ServerSwitch: 8 bytes, no semantic parse yet (we just consume it
+ // for checksum coverage — ACE doesn't decode these either).
+ if (HasFlag(flags, PacketHeaderFlags.ServerSwitch))
+ {
+ if (!Take(body, ref pos, 8)) return -1;
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.RequestRetransmit))
+ {
+ if (!Take(body, ref pos, 4)) return -1;
+ uint count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
+ // Bounds: count * 4 bytes must fit.
+ if (count > 1024 || body.Length - pos < (int)count * 4) return -1;
+ var list = new uint[count];
+ for (int i = 0; i < count; i++)
+ {
+ list[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ }
+ RetransmitRequests = list;
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.RejectRetransmit))
+ {
+ if (!Take(body, ref pos, 4)) return -1;
+ uint count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
+ if (count > 1024 || body.Length - pos < (int)count * 4) return -1;
+ pos += (int)count * 4; // consume without storing
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.AckSequence))
+ {
+ if (!Take(body, ref pos, 4)) return -1;
+ AckSequence = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
+ }
+
+ // LoginRequest: the remaining body (after all prior optional sections)
+ // is the login payload. ACE reads it without advancing position so
+ // the fragment loop can re-read it from the start — our parser mirrors
+ // that behavior by "peeking" the remaining bytes into RawBytes without
+ // advancing pos beyond this point in the main pipeline. Callers that
+ // want the login blob should read from body[pos..] directly.
+ //
+ // Note: LoginRequest packets have no fragments after this point.
+ if (HasFlag(flags, PacketHeaderFlags.LoginRequest))
+ {
+ // Count the entire remaining body as part of the optional section
+ // for checksum purposes, and stop parsing here.
+ int loginBytes = body.Length - pos;
+ if (loginBytes < 0) return -1;
+ RawBytes = body.Slice(0, pos + loginBytes).ToArray();
+ return pos + loginBytes;
+ }
+
+ // WorldLoginRequest: 8 bytes peeked (not advanced in ACE either — our
+ // consumers treat it as covered by checksum, not advanced).
+ // Strict port: we DO advance pos so the fragment loop reads after it,
+ // matching the on-wire byte layout. ACE's peek-and-reset is an
+ // implementation detail of its MemoryStream seeking that doesn't
+ // affect the bytes-consumed count.
+ if (HasFlag(flags, PacketHeaderFlags.WorldLoginRequest))
+ {
+ if (!Take(body, ref pos, 8)) return -1;
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.ConnectResponse))
+ {
+ if (!Take(body, ref pos, 8)) return -1;
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.CICMDCommand))
+ {
+ if (!Take(body, ref pos, 8)) return -1;
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.TimeSync))
+ {
+ if (!Take(body, ref pos, 8)) return -1;
+ TimeSync = BitConverter.Int64BitsToDouble(
+ BinaryPrimitives.ReadInt64LittleEndian(body.Slice(pos - 8)));
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.EchoRequest))
+ {
+ if (!Take(body, ref pos, 4)) return -1;
+ EchoRequestClientTime = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos - 4));
+ }
+
+ if (HasFlag(flags, PacketHeaderFlags.Flow))
+ {
+ if (!Take(body, ref pos, 6)) return -1;
+ FlowBytes = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 6));
+ FlowInterval = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos - 2));
+ }
+
+ RawBytes = body.Slice(0, pos).ToArray();
+ return pos;
+ }
+
+ ///
+ /// Hash32 over the raw bytes consumed during . Forms
+ /// one of the three summands in the packet's CRC: header + optional +
+ /// fragments.
+ ///
+ public uint CalculateHash32() => Cryptography.Hash32.Calculate(RawBytes);
+
+ private static bool HasFlag(PacketHeaderFlags all, PacketHeaderFlags bit) => (all & bit) != 0;
+
+ private static bool Take(ReadOnlySpan body, ref int pos, int n)
+ {
+ if (body.Length - pos < n) return false;
+ pos += n;
+ return true;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs
new file mode 100644
index 0000000..c410787
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs
@@ -0,0 +1,171 @@
+using AcDream.Core.Net.Cryptography;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests.Packets;
+
+public class PacketCodecTests
+{
+ ///
+ /// Build a complete packet byte array by hand, compute its correct
+ /// unencrypted CRC, and verify TryDecode parses it and accepts the CRC.
+ ///
+ [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);
+ }
+}