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:
parent
3226c4bcab
commit
c64bbf29e4
4 changed files with 499 additions and 0 deletions
171
src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
Normal file
171
src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue