feat(net): PacketHeaderOptional + full packet decode + CRC verify (Phase 4.4)

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>
This commit is contained in:
Erik 2026-04-11 14:24:29 +02:00
parent 3226c4bcab
commit c64bbf29e4
4 changed files with 499 additions and 0 deletions

View file

@ -0,0 +1,28 @@
namespace AcDream.Core.Net.Packets;
/// <summary>
/// A fully-parsed AC UDP packet: 20-byte <see cref="Header"/>,
/// variable-length <see cref="Optional"/> section, zero or more
/// <see cref="Fragments"/>, 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).
///
/// <para>
/// Produced by <see cref="PacketCodec.TryDecode"/> on the receive path;
/// passed to <see cref="PacketCodec.Encode"/> on the transmit path after
/// callers fill in the fields they want.
/// </para>
/// </summary>
public sealed class Packet
{
public PacketHeader Header;
public PacketHeaderOptional Optional { get; } = new();
public List<MessageFragment> Fragments { get; } = new();
/// <summary>
/// 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.
/// </summary>
public byte[] BodyBytes { get; set; } = Array.Empty<byte>();
}

View file

@ -0,0 +1,129 @@
using AcDream.Core.Net.Cryptography;
namespace AcDream.Core.Net.Packets;
/// <summary>
/// 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 <see cref="Packet"/> on
/// success or a <see cref="PacketDecodeResult"/> error for the caller
/// to log and drop.
///
/// <para>
/// CRC verification algorithm (ported from ACE <c>ClientPacket.VerifyCRC</c>):
/// <code>
/// 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)
/// </code>
/// </para>
/// </summary>
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;
}
/// <summary>
/// Parse a UDP datagram into a <see cref="Packet"/> and verify its CRC.
/// </summary>
/// <param name="datagram">The raw UDP payload.</param>
/// <param name="inboundIsaac">
/// ISAAC keystream for checking encrypted checksums on inbound packets,
/// or <c>null</c> if the packet's checksum will not be encrypted (any
/// packet without the EncryptedChecksum flag is verified additively).
/// </param>
public static PacketDecodeResult TryDecode(ReadOnlySpan<byte> 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);
}
/// <summary>
/// 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.
/// </summary>
public static uint CalculateFragmentHash32(in MessageFragment frag)
{
Span<byte> headerBuf = stackalloc byte[MessageFragmentHeader.Size];
frag.Header.Pack(headerBuf);
return Hash32.Calculate(headerBuf) + Hash32.Calculate(frag.Payload);
}
}

View file

@ -0,0 +1,171 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Packets;
/// <summary>
/// The variable-sized "optional header" section that lives between the
/// 20-byte <see cref="PacketHeader"/> and the body fragments (or login
/// payload) of an AC UDP packet. Each sub-section is gated by a flag in
/// <c>header.Flags</c> — no flags, no optional section.
///
/// <para>
/// Fields that actually exist once parsed (all default-zeroed when the
/// corresponding flag isn't set):
/// </para>
/// <list type="bullet">
/// <item><see cref="AckSequence"/> — present when <see cref="PacketHeaderFlags.AckSequence"/> is set.</item>
/// <item><see cref="RetransmitRequests"/> — list of sequence numbers to
/// retransmit, present when <see cref="PacketHeaderFlags.RequestRetransmit"/> is set.</item>
/// <item><see cref="TimeSync"/> — server time sync value, present when
/// <see cref="PacketHeaderFlags.TimeSync"/> is set.</item>
/// <item><see cref="EchoRequestClientTime"/> — present on echo requests.</item>
/// <item><see cref="FlowBytes"/>/<see cref="FlowInterval"/> — flow control, present on Flow packets.</item>
/// </list>
///
/// <para>
/// The parser <b>also records the raw bytes it consumed</b> in
/// <see cref="RawBytes"/> — AC's CRC verification requires hashing this
/// optional section separately from the main header and the fragments, so
/// the byte slice is needed downstream.
/// </para>
///
/// Reimplemented from ACE's AGPL reference; see <c>NOTICE.md</c>.
/// </summary>
public sealed class PacketHeaderOptional
{
/// <summary>Raw bytes consumed from the wire, used by <see cref="CalculateHash32"/>.</summary>
public byte[] RawBytes { get; private set; } = Array.Empty<byte>();
public uint AckSequence { get; private set; }
public IReadOnlyList<uint> RetransmitRequests { get; private set; } = Array.Empty<uint>();
public double TimeSync { get; private set; }
public float EchoRequestClientTime { get; private set; }
public uint FlowBytes { get; private set; }
public ushort FlowInterval { get; private set; }
/// <summary>
/// Parse the optional section from <paramref name="body"/> (which starts
/// right after the 20-byte header). Returns the number of bytes consumed
/// from <paramref name="body"/>, or -1 if the section is malformed
/// (short reads, impossible lengths). On success, <see cref="RawBytes"/>
/// holds a copy of the consumed slice.
/// </summary>
public int Parse(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Hash32 over the raw bytes consumed during <see cref="Parse"/>. Forms
/// one of the three summands in the packet's CRC: header + optional +
/// fragments.
/// </summary>
public uint CalculateHash32() => Cryptography.Hash32.Calculate(RawBytes);
private static bool HasFlag(PacketHeaderFlags all, PacketHeaderFlags bit) => (all & bit) != 0;
private static bool Take(ReadOnlySpan<byte> body, ref int pos, int n)
{
if (body.Length - pos < n) return false;
pos += n;
return true;
}
}