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>
This commit is contained in:
parent
18e308fe85
commit
3226c4bcab
6 changed files with 527 additions and 0 deletions
111
src/AcDream.Core.Net/Packets/FragmentAssembler.cs
Normal file
111
src/AcDream.Core.Net/Packets/FragmentAssembler.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles multi-fragment GameMessages. UDP packets can arrive in any
|
||||
/// order and individual fragments within a logical message can be split
|
||||
/// across packets, so we buffer partial messages keyed by fragment Id and
|
||||
/// only yield a complete byte stream once every <c>Count</c> fragment for
|
||||
/// that Id has arrived.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Correctness properties:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>Out-of-order arrival: fragments can arrive in any index order;
|
||||
/// the full message is released on the last fragment regardless of
|
||||
/// its index.</item>
|
||||
/// <item>Duplicate-fragment idempotence: receiving index N twice for the
|
||||
/// same Id is harmless — the second copy is silently ignored.</item>
|
||||
/// <item>Single-fragment messages: Count=1 releases immediately on
|
||||
/// that one fragment with no buffering.</item>
|
||||
/// <item>Orphaned partials: if fragments for an Id arrive but the message
|
||||
/// never completes, they stay buffered until
|
||||
/// <see cref="DropAll"/> is called or the assembler is disposed.
|
||||
/// A future phase will add a TTL-based eviction.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FragmentAssembler
|
||||
{
|
||||
private readonly Dictionary<uint, PartialMessage> _inFlight = new();
|
||||
|
||||
/// <summary>
|
||||
/// Number of logical messages currently partially-assembled (waiting on
|
||||
/// more fragments to arrive).
|
||||
/// </summary>
|
||||
public int PartialCount => _inFlight.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Ingest one fragment. If this fragment completes a message, returns
|
||||
/// the fully-assembled payload as a new byte array. Otherwise returns
|
||||
/// <c>null</c> and the fragment is held for later assembly.
|
||||
/// </summary>
|
||||
/// <param name="fragment">The decoded fragment from the wire.</param>
|
||||
/// <param name="messageQueue">
|
||||
/// Filled with the completed message's GameMessageGroup (queue) if the
|
||||
/// call returns a non-null payload; otherwise 0.
|
||||
/// </param>
|
||||
public byte[]? Ingest(in MessageFragment fragment, out ushort messageQueue)
|
||||
{
|
||||
var h = fragment.Header;
|
||||
messageQueue = 0;
|
||||
|
||||
// Single-fragment message: shortcut to avoid the dictionary.
|
||||
if (h.Count == 1 && h.Index == 0)
|
||||
{
|
||||
messageQueue = h.Queue;
|
||||
return fragment.Payload;
|
||||
}
|
||||
|
||||
if (!_inFlight.TryGetValue(h.Id, out var partial))
|
||||
{
|
||||
partial = new PartialMessage(h.Count, h.Queue);
|
||||
_inFlight[h.Id] = partial;
|
||||
}
|
||||
|
||||
// Idempotent: receiving the same index twice is not an error.
|
||||
if (partial.Fragments[h.Index] is null)
|
||||
{
|
||||
partial.Fragments[h.Index] = fragment.Payload;
|
||||
partial.ReceivedCount++;
|
||||
}
|
||||
|
||||
if (partial.ReceivedCount < partial.TotalFragments)
|
||||
return null;
|
||||
|
||||
// All fragments present — concatenate and release.
|
||||
int totalBytes = 0;
|
||||
for (int i = 0; i < partial.TotalFragments; i++)
|
||||
totalBytes += partial.Fragments[i]!.Length;
|
||||
|
||||
var combined = new byte[totalBytes];
|
||||
int offset = 0;
|
||||
for (int i = 0; i < partial.TotalFragments; i++)
|
||||
{
|
||||
var p = partial.Fragments[i]!;
|
||||
Buffer.BlockCopy(p, 0, combined, offset, p.Length);
|
||||
offset += p.Length;
|
||||
}
|
||||
|
||||
_inFlight.Remove(h.Id);
|
||||
messageQueue = partial.Queue;
|
||||
return combined;
|
||||
}
|
||||
|
||||
/// <summary>Discard all in-flight partial messages.</summary>
|
||||
public void DropAll() => _inFlight.Clear();
|
||||
|
||||
private sealed class PartialMessage
|
||||
{
|
||||
public readonly byte[]?[] Fragments;
|
||||
public readonly int TotalFragments;
|
||||
public readonly ushort Queue;
|
||||
public int ReceivedCount;
|
||||
|
||||
public PartialMessage(int count, ushort queue)
|
||||
{
|
||||
TotalFragments = count;
|
||||
Fragments = new byte[count][];
|
||||
Queue = queue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue