acdream/src/AcDream.Core.Net/Packets/MessageFragmentHeader.cs
Erik 3226c4bcab feat(net): message fragment header + fragment + assembler (Phase 4.3)
Ports the fragment layer of the AC UDP protocol. A UDP packet's body is
zero or more message fragments back-to-back; a logical GameMessage that
doesn't fit in ~448 bytes gets split across multiple fragments sharing
the same Id with differing Index values. The assembler handles
reassembly across arbitrary arrival ordering and duplicate fragments.

Added (all reimplemented from ACE's AGPL reference, see NOTICE.md):
  - Packets/MessageFragmentHeader.cs: 16-byte fragment header struct
    with Pack/Unpack, constants for MaxFragmentSize (464) and
    MaxFragmentDataSize (448). Bit-layout doc comment documents what
    each field is for.
  - Packets/MessageFragment.cs: readonly record struct bundling a
    header with its payload bytes; TryParse(source) parses one fragment
    from the start of a buffer and returns (fragment, consumed) for
    incremental parsing of multi-fragment packets. Refuses to parse
    fragments with impossible TotalSize (too small for header, too
    large for the 464-byte max, or larger than the source buffer).
  - Packets/FragmentAssembler.cs: buffers partial messages keyed by
    fragment Id. Ingest(frag, out queue) returns the assembled byte[]
    when the last fragment arrives, null while still waiting. Key
    correctness properties, all tested:
      * Single-fragment (Count=1) shortcut releases with no buffering
      * Out-of-order arrival (e.g. 2, 0, 1) releases on last arrival
        and assembles in INDEX order, not arrival order
      * Duplicate-fragment idempotence (re-sending same index is a no-op)
      * Missing fragments stay buffered; DropAll() forcibly clears them
      * Two independent messages can be assembled in parallel without
        interfering
      * messageQueue captured from first-arriving fragment (it's a
        property of the logical message, not individual fragments)

Tests (17 new, 37 total in net project, 114 across both test projects):
  - MessageFragmentHeader (4): pack/unpack round-trip, little-endian
    wire format, constants, size-check throw
  - MessageFragment (6): complete parse, insufficient header, oversized
    TotalSize, undersized TotalSize, incomplete body, two-back-to-back
    incremental parse
  - FragmentAssembler (7): single-fragment, in-order 3-fragment,
    out-of-order 3-fragment (tests index-order assembly), duplicate
    idempotence, missing-fragment buffered, two parallel messages,
    DropAll

Phase 4.4 (GameMessage reader + opcode handlers) next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:20:53 +02:00

77 lines
3.4 KiB
C#

using System.Buffers.Binary;
namespace AcDream.Core.Net.Packets;
/// <summary>
/// The 16-byte header that prefixes every message fragment inside a UDP
/// packet's body. A single UDP packet can contain one or more fragments,
/// and a single logical <c>GameMessage</c> can be split across multiple
/// fragments that share the same <see cref="Id"/> and <see cref="Sequence"/>
/// but differ in <see cref="Index"/>.
///
/// <para>
/// Layout (byte offsets):
/// <code>
/// 0 Sequence uint32 Fragment-level sequence
/// 4 Id uint32 Message id — fragments with the same Id belong
/// to the same logical GameMessage. Outbound
/// messages use the high bit set (0x80000000+).
/// 8 Count uint16 Total fragments in the logical message (1 if
/// the message fits in a single fragment)
/// 10 Size uint16 Total fragment size including this 16-byte
/// header — max 464 bytes (448 bytes of payload)
/// 12 Index uint16 0-based position of this fragment in the
/// logical message
/// 14 Queue uint16 GameMessageGroup — used by the server to
/// sequence related messages
/// </code>
/// </para>
///
/// Reimplemented from ACE's AGPL reference; see <c>NOTICE.md</c>.
/// </summary>
public struct MessageFragmentHeader
{
public const int Size = 16;
/// <summary>Max total fragment size on the wire (including this header).</summary>
public const int MaxFragmentSize = 464;
/// <summary>Max payload bytes per fragment (= MaxFragmentSize - Size).</summary>
public const int MaxFragmentDataSize = MaxFragmentSize - Size; // 448
public uint Sequence;
public uint Id;
public ushort Count;
public ushort TotalSize; // total bytes of this fragment including header
public ushort Index;
public ushort Queue;
public readonly void Pack(Span<byte> destination)
{
if (destination.Length < Size)
throw new ArgumentException($"destination must be at least {Size} bytes", nameof(destination));
BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(0), Sequence);
BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(4), Id);
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(8), Count);
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(10), TotalSize);
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(12), Index);
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(14), Queue);
}
public static MessageFragmentHeader Unpack(ReadOnlySpan<byte> source)
{
if (source.Length < Size)
throw new ArgumentException($"source must be at least {Size} bytes", nameof(source));
return new MessageFragmentHeader
{
Sequence = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(0)),
Id = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(4)),
Count = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(8)),
TotalSize = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(10)),
Index = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(12)),
Queue = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(14)),
};
}
}