Campaign N slice N0 (docs/plans/2026-07-29-network-transport-campaign.md): the referee that slices N1-N5 are graded against, test-project only, zero production changes. - VirtualClock: Stopwatch-shaped deterministic time source (fixed 100 ns ticks) that N1 will inject behind the production TransportClock. - AceCryptoModel: verbatim port of ACE CryptoSystem Search/ConsumeKey over our IsaacRandom - 256-key window, parked-key set, Headroom/OrphanCount diagnostics (CryptoSystem.cs:8-49 cited per method). - AceSessionModel: transport-free ACE NetworkSession over raw datagrams, every rule cited to NetworkSession.cs - CRC-before-everything silent drop, cleartext-NAK early return (no timeout refresh, :283-308), 60 s timeout refresh (:329-331), exact-equality ack dedup exemption (:342-347), desired+2 NAK trigger with 1 s limit (:351-363), >window AbnormalSequenceReceived (:393-397), the :474-476 watermark hole, ack-value cache prune (:663-673), fragment gate (:532-543), seq>=2 caching (:730), Retransmission-flag resends with the ORIGINAL IssacXor (:675-686), RejectRetransmit, 2 s cleartext cumulative ack, 20 s TimeSync, EchoResponse, 120 s cache prune (:251-262). ACE's raw wrap-unsafe comparisons are modeled bug-for-bug, not fixed. - LossyLink: deterministic drop/reorder/seeded-loss fault injector, pure data structure. - FakeAceTransport: IWorldSessionTransport binding a REAL WorldSession to the model through the link, with the handshake scripted (ConnectRequest reusing the negotiation fixture layout, CharacterList, ServerReady, logoff confirmation) - genuine Connect/EnterWorld/Tick/Dispose with no sockets. - 19 new tests pin the double, including CleartextNonAckAdvancesWatermark_TheAceHole (the self-induced wedge behind scope rows TS-57/TS-58/AP-125), re-key = permanent orphan, unrequested-resend window burn, the 115-id NAK cap boundary, and a full no-socket session lifecycle with both ISAAC streams verified aligned end-to-end. Core.Net suite: 678 passed / 0 failed (659 existing + 19 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
959 lines
40 KiB
C#
959 lines
40 KiB
C#
using System.Buffers.Binary;
|
|
using AcDream.Core.Net.Cryptography;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Net.Packets;
|
|
|
|
namespace AcDream.Core.Net.Tests.Transport;
|
|
|
|
/// <summary>
|
|
/// Termination causes the double can hit, mirroring ACE's
|
|
/// <c>SessionTerminationReason</c> names for the modeled paths.
|
|
/// </summary>
|
|
internal enum AceTerminationReason
|
|
{
|
|
None,
|
|
/// <summary>NetworkSession.cs:312-315 — client sent a Disconnect header.</summary>
|
|
PacketHeaderDisconnect,
|
|
/// <summary>NetworkSession.cs:318-321 — client sent NetErrorDisconnect.</summary>
|
|
ClientSentNetworkErrorDisconnect,
|
|
/// <summary>NetworkSession.cs:393-397 — sequence gap beyond the crypto search window.</summary>
|
|
AbnormalSequenceReceived,
|
|
/// <summary>TimeoutTick (NetworkSession.cs:88, :329-331) expired — every ACE transport death is silence.</summary>
|
|
NetworkTimeout,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transport-free model of ACE's per-connection <c>NetworkSession</c>
|
|
/// receive + send behavior, operating on raw datagrams (byte[]). This is the
|
|
/// Campaign N test double: slices N1-N5 are graded against it, so every rule
|
|
/// carries its citation into
|
|
/// <c>references/ACE/Source/ACE.Server/Network/NetworkSession.cs</c> (or the
|
|
/// named ACE file). It deliberately reproduces ACE's raw (wrap-unsafe)
|
|
/// sequence comparisons and the exact-equality flag checks — do NOT "fix"
|
|
/// them; they are the environment acdream must survive.
|
|
///
|
|
/// <para>
|
|
/// Time comes exclusively from an injected <see cref="VirtualClock"/> — no
|
|
/// wall clock anywhere. The model is single-threaded by design; callers
|
|
/// (see <see cref="FakeAceTransport"/>) serialize access.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Intentional simplifications, none affecting the pinned rules:
|
|
/// <list type="bullet">
|
|
/// <item>The initial timeout horizon is the 60 s in-world value; ACE's
|
|
/// 15 s pre-auth window (NetworkSession.cs:102-103) is not modeled.
|
|
/// Timeout expiry is checked in <see cref="Update"/> (ACE checks
|
|
/// <c>TimeoutTick</c> from the WorldManager loop).</item>
|
|
/// <item>The 5 ms inter-bundle pacing delay (NetworkSession.cs:30, :244)
|
|
/// is not modeled — it is send pacing, not protocol behavior, and
|
|
/// would deadlock a virtual clock that only tests advance.</item>
|
|
/// <item>Each enqueued game message flushes as its own packet; ACE
|
|
/// coalesces same-group fragments up to 464 bytes
|
|
/// (NetworkSession.cs:828-918). Sequencing/caching semantics are
|
|
/// identical either way because our messages are all ≤448 B.</item>
|
|
/// <item>Ack/TimeSync/EchoResponse emission is gated on the handshake
|
|
/// being complete (ACE cannot address S2C traffic before it knows
|
|
/// the endpoint; pre-handshake the timers cannot have fired in
|
|
/// practice).</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class AceSessionModel
|
|
{
|
|
// ---- ACE constants, cited ----
|
|
/// <summary>NetworkSession.cs:381 — max NAK ids per RequestRetransmit.</summary>
|
|
private const int MaxNumNakSeqIds = 115;
|
|
/// <summary>NetworkSession.cs:359 — `new TimeSpan(0, 0, 1)` NAK rate limit.</summary>
|
|
private static readonly long NakRateLimitTicks = TimeSpan.FromSeconds(1).Ticks;
|
|
/// <summary>NetworkSession.cs:32 — timeBetweenAck = 2000 ms.</summary>
|
|
private static readonly long AckIntervalTicks = TimeSpan.FromSeconds(2).Ticks;
|
|
/// <summary>NetworkSession.cs:31 — timeBetweenTimeSync = 20000 ms.</summary>
|
|
private static readonly long TimeSyncIntervalTicks = TimeSpan.FromSeconds(20).Ticks;
|
|
/// <summary>NetworkManager.DefaultSessionTimeout (60 s), applied at NetworkSession.cs:329-331.</summary>
|
|
private static readonly long SessionTimeoutTicks = TimeSpan.FromSeconds(60).Ticks;
|
|
/// <summary>NetworkSession.cs:67 — cachedPacketPruneInterval = 5 s.</summary>
|
|
private static readonly long CachePruneIntervalTicks = TimeSpan.FromSeconds(5).Ticks;
|
|
/// <summary>NetworkSession.cs:72 — cachedPacketRetentionTime = 120 s.</summary>
|
|
private const int CachedPacketRetentionSeconds = 120;
|
|
|
|
// ---- identity / handshake material ----
|
|
private readonly VirtualClock _clock;
|
|
private readonly ushort _serverId;
|
|
private readonly uint _clientId;
|
|
private readonly ulong _cookie;
|
|
private readonly uint _clientSeed;
|
|
private readonly uint _serverSeed;
|
|
|
|
/// <summary>C2S verifier — SessionConnectionData.CryptoClient (SessionConnectionData.cs:61).</summary>
|
|
public AceCryptoModel Crypto { get; }
|
|
|
|
/// <summary>S2C keystream — SessionConnectionData.IssacServer (SessionConnectionData.cs:62).</summary>
|
|
private readonly IsaacRandom _s2cKeystream;
|
|
|
|
// ---- receive state ----
|
|
/// <summary>NetworkSession.cs:57 — starts at 1.</summary>
|
|
private uint _lastReceivedPacketSequence = 1;
|
|
/// <summary>NetworkSession.cs:58 — starts at 0.</summary>
|
|
private uint _lastReceivedFragmentSequence;
|
|
/// <summary>NetworkSession.cs:41 — outOfOrderPackets (parsed + CRC-verified; never re-verified).</summary>
|
|
private readonly Dictionary<uint, ParsedPacket> _outOfOrderPackets = new();
|
|
/// <summary>NetworkSession.cs:42 — partialFragments (multi-fragment C2S reassembly).</summary>
|
|
private readonly Dictionary<uint, PartialC2SMessage> _partialFragments = new();
|
|
/// <summary>NetworkSession.cs:43 — outOfOrderFragments (the C2S fragment gate buffer).</summary>
|
|
private readonly Dictionary<uint, byte[]> _outOfOrderFragments = new();
|
|
/// <summary>NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null).</summary>
|
|
private long? _lastNakTimestamp;
|
|
private float? _pendingEchoClientTime;
|
|
|
|
// ---- send state ----
|
|
/// <summary>
|
|
/// ACE's ConnectionData.PacketSequence is UIntSequence(clientPrimed:false)
|
|
/// (SessionConnectionData.cs:66): CurrentValue starts at uint.MaxValue and
|
|
/// the first NextValue wraps to 0 (UIntSequence.cs:19-41), so the
|
|
/// cleartext ConnectRequest goes out with sequence 0. The first ENCRYPTED
|
|
/// flush re-primes CurrentValue to 1 (NetworkSession.cs:716-717), making
|
|
/// the first encrypted S2C packet sequence 2.
|
|
/// </summary>
|
|
private uint _packetSequence = uint.MaxValue;
|
|
/// <summary>SessionConnectionData.FragmentSequence — default 0; assigned at bundle flush (NetworkSession.cs:821).</summary>
|
|
private uint _s2cFragmentSequence;
|
|
/// <summary>NetworkSession.cs:65 — cachedPackets, keyed by sequence.</summary>
|
|
private readonly Dictionary<uint, CachedS2CPacket> _cachedPackets = new();
|
|
private long? _lastPruneTimestamp;
|
|
private long _nextAckTimestamp;
|
|
private long? _nextResyncTimestamp;
|
|
private bool _sendResync;
|
|
private bool _handshakeComplete;
|
|
private readonly List<(byte[] Body, GameMessageGroup Group)> _pendingMessages = new();
|
|
/// <summary>NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update.</summary>
|
|
private readonly Queue<OutboundDraft> _flushQueue = new();
|
|
|
|
// ---- observable outputs ----
|
|
private readonly List<byte[]> _dispatchedMessages = new();
|
|
private readonly List<byte[]> _sentDatagrams = new();
|
|
private readonly Queue<byte[]> _pendingOutbound = new();
|
|
|
|
public AceSessionModel(
|
|
VirtualClock clock,
|
|
uint clientSeed,
|
|
uint serverSeed,
|
|
uint clientId,
|
|
ulong cookie,
|
|
ushort serverId = 0x000C)
|
|
{
|
|
_clock = clock;
|
|
_clientSeed = clientSeed;
|
|
_serverSeed = serverSeed;
|
|
_clientId = clientId;
|
|
_cookie = cookie;
|
|
_serverId = serverId;
|
|
|
|
Crypto = new AceCryptoModel(clientSeed);
|
|
Span<byte> seedBytes = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, serverSeed);
|
|
_s2cKeystream = new IsaacRandom(seedBytes);
|
|
|
|
// NetworkSession.cs:54-55 — sendAck starts true with the 2 s delay armed.
|
|
_nextAckTimestamp = clock.GetTimestamp() + AckIntervalTicks;
|
|
// Simplified from NetworkSession.cs:102-103 (15 s pre-auth window);
|
|
// the double pins the 60 s in-world horizon of :329-331 only.
|
|
TimeoutDeadlineTimestamp = clock.GetTimestamp() + SessionTimeoutTicks;
|
|
}
|
|
|
|
// ---- diagnostics for assertions ----
|
|
public uint LastReceivedPacketSequence => _lastReceivedPacketSequence;
|
|
public uint LastReceivedFragmentSequence => _lastReceivedFragmentSequence;
|
|
/// <summary>Fully-assembled C2S message bodies in ACE dispatch order.</summary>
|
|
public IReadOnlyList<byte[]> DispatchedMessages => _dispatchedMessages;
|
|
/// <summary>Every S2C datagram the model has emitted, in send order (cumulative).</summary>
|
|
public IReadOnlyList<byte[]> SentDatagrams => _sentDatagrams;
|
|
public bool IsTerminated { get; private set; }
|
|
public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None;
|
|
/// <summary>VirtualClock timestamp past which <see cref="Update"/> terminates the session.</summary>
|
|
public long TimeoutDeadlineTimestamp { get; private set; }
|
|
public int OutOfOrderPacketCount => _outOfOrderPackets.Count;
|
|
/// <summary>Completed messages parked behind the C2S fragment gate (NetworkSession.cs:539-542).</summary>
|
|
public int FragmentGateBufferCount => _outOfOrderFragments.Count;
|
|
public int PartialFragmentBufferCount => _partialFragments.Count;
|
|
public int CachedPacketCount => _cachedPackets.Count;
|
|
public IReadOnlyCollection<uint> CachedPacketSequences => _cachedPackets.Keys;
|
|
/// <summary>Packets silently dropped by CRC/Search failure (NetworkSession.cs:277-280).</summary>
|
|
public int CrcDropCount { get; private set; }
|
|
/// <summary>Packets dropped by the duplicate-rejection rule (NetworkSession.cs:342-347).</summary>
|
|
public int DuplicateDropCount { get; private set; }
|
|
public int RetransmitsServed { get; private set; }
|
|
|
|
// ---- script hooks for FakeAceTransport ----
|
|
/// <summary>Fired when a LoginRequest packet is handled (NetworkSession.cs:463-468).</summary>
|
|
public event Action? LoginRequestReceived;
|
|
/// <summary>Fired when a cookie-matching ConnectResponse is accepted (NetworkManager.cs:50-79).</summary>
|
|
public event Action? ConnectResponseAccepted;
|
|
/// <summary>Fired per dispatched C2S message body, in ACE dispatch order.</summary>
|
|
public event Action<byte[]>? MessageDispatched;
|
|
|
|
/// <summary>Drain the datagrams emitted since the last call, in send order.</summary>
|
|
public List<byte[]> TakePendingDatagrams()
|
|
{
|
|
var drained = new List<byte[]>(_pendingOutbound.Count);
|
|
while (_pendingOutbound.TryDequeue(out byte[]? datagram))
|
|
drained.Add(datagram);
|
|
return drained;
|
|
}
|
|
|
|
// =====================================================================
|
|
// Receive pipeline — NetworkSession.ProcessPacket (:269-379), in ACE's
|
|
// exact order.
|
|
// =====================================================================
|
|
public void Receive(ReadOnlySpan<byte> datagram)
|
|
{
|
|
if (IsTerminated)
|
|
return; // isReleased guard (:271-272)
|
|
|
|
if (!TryParse(datagram, out ParsedPacket packet))
|
|
return; // ClientPacket.Unpack failure — ConnectionListener discards silently
|
|
|
|
// ConnectResponse is routed by flag BEFORE the session pipeline
|
|
// (NetworkManager.cs:50-79): its CRC is never verified (VerifyCRC only
|
|
// runs inside NetworkSession.ProcessPacket) and no dedup/watermark
|
|
// applies — the 64-bit cookie is the authenticator
|
|
// (PacketInboundConnectResponse).
|
|
if ((packet.Header.Flags & PacketHeaderFlags.ConnectResponse) != 0)
|
|
{
|
|
HandleConnectResponse(packet);
|
|
return;
|
|
}
|
|
|
|
// 1. CRC verification (:277-280). Failure → silent drop; note the
|
|
// timeout refresh below is NOT reached, so a CRC-failing flood
|
|
// cannot keep a session alive.
|
|
if (!VerifyCrc(packet))
|
|
{
|
|
CrcDropCount++;
|
|
return;
|
|
}
|
|
|
|
// 2. Cleartext-NAK early handling (:283-308): RequestRetransmit set
|
|
// AND EncryptedChecksum NOT set → serve retransmits (immediate raw
|
|
// sends), queue RejectRetransmit for uncached ids, and RETURN —
|
|
// before the timeout refresh, so NAKs never refresh ACE's 60 s
|
|
// timeout. Encrypted NAKs fall through and are effectively
|
|
// ignored (:283-284 requires the cleartext form).
|
|
if ((packet.Header.Flags & PacketHeaderFlags.RequestRetransmit) != 0
|
|
&& (packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) == 0)
|
|
{
|
|
List<uint>? uncached = null;
|
|
foreach (uint sequence in packet.Optional.RetransmitRequests)
|
|
{
|
|
if (!TryRetransmit(sequence))
|
|
(uncached ??= new List<uint>()).Add(sequence);
|
|
}
|
|
|
|
if (uncached is not null)
|
|
EnqueueRejectRetransmit(uncached); // :299-304 (sent on the next Update flush)
|
|
return; // :307
|
|
}
|
|
|
|
// 3. Disconnect headers (:312-321).
|
|
if ((packet.Header.Flags & PacketHeaderFlags.Disconnect) != 0)
|
|
{
|
|
Terminate(AceTerminationReason.PacketHeaderDisconnect);
|
|
return;
|
|
}
|
|
|
|
if ((packet.Header.Flags & PacketHeaderFlags.NetErrorDisconnect) != 0)
|
|
{
|
|
Terminate(AceTerminationReason.ClientSentNetworkErrorDisconnect);
|
|
return;
|
|
}
|
|
|
|
// 4. Timeout refresh (:329-331) — 60 s in-world horizon.
|
|
TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks;
|
|
|
|
// 5. Duplicate rejection (:342-347). Raw unsigned comparison — NOT
|
|
// wrap-safe, exactly like ACE (a wrapped client sequence would be
|
|
// mis-classified; modeled bug-for-bug). The ack-only exemption is
|
|
// an EQUALITY check on the whole flags field, never HasFlag, and
|
|
// only at seq == watermark exactly.
|
|
if (packet.Header.Sequence <= _lastReceivedPacketSequence
|
|
&& packet.Header.Sequence != 0
|
|
&& !(packet.Header.Flags == PacketHeaderFlags.AckSequence
|
|
&& packet.Header.Sequence == _lastReceivedPacketSequence))
|
|
{
|
|
DuplicateDropCount++;
|
|
return;
|
|
}
|
|
|
|
// 6. Out-of-order buffering (:351-363). NAK trigger fires only at
|
|
// desiredSeq + 2 ≤ arrivedSeq, arrival-driven, with a 1 s rate
|
|
// limit; a quiet link is never NAKed.
|
|
uint desiredSeq = _lastReceivedPacketSequence + 1;
|
|
if (packet.Header.Sequence > desiredSeq)
|
|
{
|
|
if (!_outOfOrderPackets.ContainsKey(packet.Header.Sequence))
|
|
_outOfOrderPackets.Add(packet.Header.Sequence, packet);
|
|
|
|
bool rateLimitOpen =
|
|
_lastNakTimestamp is null
|
|
|| _clock.GetTimestamp() - _lastNakTimestamp.Value > NakRateLimitTicks;
|
|
if (desiredSeq + 2 <= packet.Header.Sequence && rateLimitOpen)
|
|
DoRequestForRetransmission(packet.Header.Sequence);
|
|
return;
|
|
}
|
|
|
|
// 7. Final processing (:367-378).
|
|
HandleOrderedPacket(packet);
|
|
CheckOutOfOrderPackets();
|
|
CheckOutOfOrderFragments();
|
|
}
|
|
|
|
/// <summary>ClientPacket.VerifyCRC (ClientPacket.cs:138-163) over the crypto model.</summary>
|
|
private bool VerifyCrc(ParsedPacket packet)
|
|
{
|
|
uint headerHash = packet.Header.CalculateHeaderHash32();
|
|
uint payloadHash = packet.Optional.CalculateHash32() + packet.FragmentHash;
|
|
|
|
if ((packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) != 0)
|
|
{
|
|
// ClientPacket.cs:140-147 — extract the key, Search, then Consume.
|
|
uint key = (packet.Header.Checksum - headerHash) ^ payloadHash;
|
|
if (Crypto.Search(key))
|
|
{
|
|
Crypto.ConsumeKey(key);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ClientPacket.cs:149-157 — additive cleartext checksum.
|
|
return headerHash + payloadHash == packet.Header.Checksum;
|
|
}
|
|
|
|
/// <summary>
|
|
/// NetworkManager.cs:50-79 — ConnectResponse routing. The double only
|
|
/// supports the exact shape retail/acdream sends (flags ==
|
|
/// ConnectResponse alone, 8-byte cookie body).
|
|
/// </summary>
|
|
private void HandleConnectResponse(ParsedPacket packet)
|
|
{
|
|
if (packet.Header.Flags != PacketHeaderFlags.ConnectResponse)
|
|
return;
|
|
if (packet.Optional.RawBytes.Length < 8)
|
|
return;
|
|
ulong cookie = BinaryPrimitives.ReadUInt64LittleEndian(packet.Optional.RawBytes);
|
|
if (cookie != _cookie)
|
|
return; // NetworkManager.cs:60-66 — cookie mismatch: no session matches, ignored
|
|
if (_handshakeComplete)
|
|
return; // NetworkManager.cs:64-65 — session must still be in AuthConnectResponse
|
|
|
|
_handshakeComplete = true;
|
|
_sendResync = true; // NetworkManager.cs:78 — first TimeSync goes out immediately (:47-50)
|
|
TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks;
|
|
ConnectResponseAccepted?.Invoke();
|
|
}
|
|
|
|
/// <summary>NetworkSession.HandleOrderedPacket (:435-477).</summary>
|
|
private void HandleOrderedPacket(ParsedPacket packet)
|
|
{
|
|
// :440-443 + :650-661 — EchoRequest flags an EchoResponse onto the
|
|
// next control-bundle flush.
|
|
if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0)
|
|
_pendingEchoClientTime = packet.Optional.EchoRequestClientTime;
|
|
|
|
// :447-448 — consume the cumulative-ack VALUE: prune the S2C cache
|
|
// strictly below it.
|
|
if ((packet.Header.Flags & PacketHeaderFlags.AckSequence) != 0)
|
|
AcknowledgeSequence(packet.Optional.AckSequence);
|
|
|
|
// :450-457 — inbound TimeSync is read and ignored.
|
|
|
|
// :463-468 — LoginRequest short-circuits to the auth handler and
|
|
// RETURNS: no fragment processing and, crucially, no watermark
|
|
// advance for LoginRequest packets.
|
|
if ((packet.Header.Flags & PacketHeaderFlags.LoginRequest) != 0)
|
|
{
|
|
LoginRequestReceived?.Invoke();
|
|
return;
|
|
}
|
|
|
|
// :471-472 — fragments.
|
|
foreach (MessageFragment fragment in packet.Fragments)
|
|
ProcessFragment(fragment);
|
|
|
|
// :474-476 — THE WATERMARK-HOLE RULE, pinned: the watermark advances
|
|
// for every packet whose Sequence != 0 && Flags != AckSequence — an
|
|
// EXACT equality check on the whole flags field. Any cleartext
|
|
// non-ack control packet reusing a live sequence number advances the
|
|
// watermark and permanently skips the real packet at that sequence.
|
|
if (packet.Header.Sequence != 0
|
|
&& packet.Header.Flags != PacketHeaderFlags.AckSequence)
|
|
{
|
|
_lastReceivedPacketSequence = packet.Header.Sequence;
|
|
}
|
|
}
|
|
|
|
/// <summary>NetworkSession.ProcessFragment (:483-544).</summary>
|
|
private void ProcessFragment(MessageFragment fragment)
|
|
{
|
|
byte[]? message = null;
|
|
|
|
if (fragment.Header.Count != 1)
|
|
{
|
|
// :489-518 — split message, buffered by fragment sequence.
|
|
if (!_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer))
|
|
{
|
|
buffer = new PartialC2SMessage(fragment.Header.Count);
|
|
_partialFragments.Add(fragment.Header.Sequence, buffer);
|
|
}
|
|
|
|
buffer.Add(fragment.Header.Index, fragment.Payload);
|
|
if (buffer.Complete)
|
|
{
|
|
message = buffer.Assemble();
|
|
_partialFragments.Remove(fragment.Header.Sequence);
|
|
}
|
|
}
|
|
else if (fragment.Payload.Length >= 4)
|
|
{
|
|
// :520-527 — unsplit; ClientMessage needs ≥ 4 bytes.
|
|
message = fragment.Payload;
|
|
}
|
|
|
|
if (message is null)
|
|
return;
|
|
|
|
// :532-543 — THE C2S FRAGMENT GATE, pinned: a completed message
|
|
// dispatches only when its fragment sequence is exactly
|
|
// lastReceivedFragmentSequence + 1; anything else parks in
|
|
// outOfOrderFragments (including OLD fragment sequences, which park
|
|
// forever — ACE bug-for-bug).
|
|
if (fragment.Header.Sequence == _lastReceivedFragmentSequence + 1)
|
|
HandleFragment(message);
|
|
else
|
|
_outOfOrderFragments.TryAdd(fragment.Header.Sequence, message);
|
|
}
|
|
|
|
/// <summary>NetworkSession.HandleFragment (:550-554).</summary>
|
|
private void HandleFragment(byte[] message)
|
|
{
|
|
_dispatchedMessages.Add(message);
|
|
MessageDispatched?.Invoke(message);
|
|
_lastReceivedFragmentSequence++;
|
|
}
|
|
|
|
/// <summary>NetworkSession.CheckOutOfOrderPackets (:559-566).</summary>
|
|
private void CheckOutOfOrderPackets()
|
|
{
|
|
while (_outOfOrderPackets.Remove(_lastReceivedPacketSequence + 1, out ParsedPacket? packet))
|
|
HandleOrderedPacket(packet);
|
|
}
|
|
|
|
/// <summary>NetworkSession.CheckOutOfOrderFragments (:571-578).</summary>
|
|
private void CheckOutOfOrderFragments()
|
|
{
|
|
while (_outOfOrderFragments.Remove(_lastReceivedFragmentSequence + 1, out byte[]? message))
|
|
HandleFragment(message);
|
|
}
|
|
|
|
/// <summary>NetworkSession.AcknowledgeSequence (:663-673) — prune strictly-older
|
|
/// cached S2C packets. Raw uint compare (`x < sequence`), NOT wrap-safe:
|
|
/// modeled exactly as ACE does it.</summary>
|
|
private void AcknowledgeSequence(uint sequence)
|
|
{
|
|
List<uint>? removal = null;
|
|
foreach (uint key in _cachedPackets.Keys)
|
|
{
|
|
if (key < sequence)
|
|
(removal ??= new List<uint>()).Add(key);
|
|
}
|
|
|
|
if (removal is null)
|
|
return;
|
|
foreach (uint key in removal)
|
|
_cachedPackets.Remove(key);
|
|
}
|
|
|
|
/// <summary>NetworkSession.DoRequestForRetransmission (:387-426).</summary>
|
|
private void DoRequestForRetransmission(uint rcvdSeq)
|
|
{
|
|
uint desiredSeq = _lastReceivedPacketSequence + 1; // :389
|
|
var needSeq = new List<uint> { desiredSeq }; // :390-391
|
|
uint bottom = desiredSeq + 1; // :392
|
|
// :393-397 — gap beyond the 256-key crypto search window is fatal.
|
|
// Note this check lives INSIDE the rate-limited call, exactly like
|
|
// ACE: a huge gap arriving while the 1 s limiter is closed does NOT
|
|
// terminate until the next NAK-eligible arrival.
|
|
if (rcvdSeq < bottom || rcvdSeq - bottom > AceCryptoModel.MaximumEffortLevel)
|
|
{
|
|
Terminate(AceTerminationReason.AbnormalSequenceReceived);
|
|
return;
|
|
}
|
|
|
|
uint seqIdCount = 1; // :398-410 — cap at 115 ids, skipping buffered arrivals
|
|
for (uint a = bottom; a < rcvdSeq; a++)
|
|
{
|
|
if (_outOfOrderPackets.ContainsKey(a))
|
|
continue;
|
|
needSeq.Add(a);
|
|
seqIdCount++;
|
|
if (seqIdCount >= MaxNumNakSeqIds)
|
|
break;
|
|
}
|
|
|
|
// :412-420 — u32 count + ids, flags RequestRetransmit, CLEARTEXT
|
|
// (ServerPacket default — no EncryptedChecksum), queued for the next
|
|
// FlushPackets pass.
|
|
byte[] body = new byte[4 + needSeq.Count * 4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)needSeq.Count);
|
|
for (int i = 0; i < needSeq.Count; i++)
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
|
body.AsSpan(4 + i * 4),
|
|
needSeq[i]);
|
|
}
|
|
|
|
_flushQueue.Enqueue(new OutboundDraft(
|
|
PacketHeaderFlags.RequestRetransmit,
|
|
body,
|
|
OptionalLength: body.Length));
|
|
|
|
_lastNakTimestamp = _clock.GetTimestamp(); // :422
|
|
}
|
|
|
|
/// <summary>NetworkSession.Retransmit (:675-708) — serve a NAKed id from the cache.</summary>
|
|
private bool TryRetransmit(uint sequence)
|
|
{
|
|
if (!_cachedPackets.TryGetValue(sequence, out CachedS2CPacket? cached))
|
|
return false; // :707 — caller collects the id for RejectRetransmit
|
|
|
|
// :681-682 — OR the Retransmission flag INTO THE CACHE ENTRY (it
|
|
// sticks for any later retransmit of the same packet).
|
|
cached.Flags |= PacketHeaderFlags.Retransmission;
|
|
|
|
// :684 SendPacketRaw → ServerPacket.CreateReadyToSendPacket
|
|
// (ServerPacket.cs:46-72): the header hash is recomputed with the new
|
|
// flags, the checksum reuses the ORIGINAL IssacXor — NO new keystream
|
|
// word is drawn — and Header.Time keeps its original flush value.
|
|
// The retransmit bypasses FlushPackets: it is emitted immediately,
|
|
// before any queued RejectRetransmit.
|
|
Emit(cached.Sequence, cached.Flags, cached.Time, cached.Body, cached.OptionalLength, cached.IsaacXor);
|
|
RetransmitsServed++;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>NetworkSession.cs:299-304 + PacketRejectRetransmit.cs:7-17 —
|
|
/// u32 count + uncached ids, cleartext, queued (flows through FlushPackets,
|
|
/// so like ACE it consumes a sequence number and can even be cached).</summary>
|
|
private void EnqueueRejectRetransmit(List<uint> uncached)
|
|
{
|
|
byte[] body = new byte[4 + uncached.Count * 4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)uncached.Count);
|
|
for (int i = 0; i < uncached.Count; i++)
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
|
body.AsSpan(4 + i * 4),
|
|
uncached[i]);
|
|
}
|
|
|
|
_flushQueue.Enqueue(new OutboundDraft(
|
|
PacketHeaderFlags.RejectRetransmit,
|
|
body,
|
|
OptionalLength: body.Length));
|
|
}
|
|
|
|
// =====================================================================
|
|
// Send side — NetworkSession.Update (:182-249) + FlushPackets (:710-735)
|
|
// + SendPacket (:737-752), driven by the virtual clock.
|
|
// =====================================================================
|
|
|
|
/// <summary>
|
|
/// One server pump: timeout check, cache prune, control bundle
|
|
/// (ack/TimeSync/EchoResponse), message bundles, then FlushPackets.
|
|
/// ACE runs this from the world tick; the double runs it whenever the
|
|
/// harness pumps.
|
|
/// </summary>
|
|
public void Update()
|
|
{
|
|
if (IsTerminated)
|
|
return;
|
|
|
|
// WorldManager's TimeoutTick check (NetworkSession.cs:88). Every ACE
|
|
// transport death is silence — no disconnect packet is ever sent.
|
|
if (_clock.GetTimestamp() > TimeoutDeadlineTimestamp)
|
|
{
|
|
Terminate(AceTerminationReason.NetworkTimeout);
|
|
return;
|
|
}
|
|
|
|
// :187-188 — prune the S2C cache every 5 s.
|
|
if (_lastPruneTimestamp is null
|
|
|| _clock.GetTimestamp() - _lastPruneTimestamp.Value > CachePruneIntervalTicks)
|
|
{
|
|
PruneCachedPackets();
|
|
}
|
|
|
|
if (_handshakeComplete)
|
|
{
|
|
BuildControlDraft();
|
|
FlushMessageBundles();
|
|
}
|
|
|
|
// FlushPackets (:710-735) — drains receive-time NAK/Reject enqueues
|
|
// first (FIFO), then this pump's bundles.
|
|
while (_flushQueue.TryDequeue(out OutboundDraft draft))
|
|
FlushOne(draft);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side game-message send. Flushed by the next <see cref="Update"/>
|
|
/// as its own BlobFragments|EncryptedChecksum packet (EnqueueSend sets
|
|
/// EncryptedChecksum, NetworkSession.cs:129).
|
|
/// </summary>
|
|
public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group) =>
|
|
_pendingMessages.Add((gameMessageBody, group));
|
|
|
|
/// <summary>
|
|
/// AuthenticationHandler → PacketOutboundConnectRequest: 32-byte
|
|
/// cleartext section (serverTime, cookie, clientId, serverSeed,
|
|
/// clientSeed, padding), queued through the normal packet flush — its
|
|
/// sequence is 0, the first NextValue of the unprimed UIntSequence.
|
|
/// </summary>
|
|
public void SendConnectRequest()
|
|
{
|
|
byte[] body = new byte[32];
|
|
BinaryPrimitives.WriteInt64LittleEndian(
|
|
body,
|
|
BitConverter.DoubleToInt64Bits(_clock.Seconds));
|
|
BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), _cookie);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), _clientId);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), _serverSeed);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), _clientSeed);
|
|
// bytes 28..31: trailing padding uint, zero.
|
|
|
|
_flushQueue.Enqueue(new OutboundDraft(
|
|
PacketHeaderFlags.ConnectRequest,
|
|
body,
|
|
OptionalLength: body.Length));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The InvalidQueue control bundle (:203-216) written per
|
|
/// WriteOptionalHeaders (:921-948): ack value, then TimeSync, then
|
|
/// EchoResponse. A pure ack is CLEARTEXT with flags exactly AckSequence;
|
|
/// TimeSync and EchoResponse force EncryptedChecksum (:207, :659), so a
|
|
/// coalesced ack+TimeSync packet is encrypted and sequenced — exactly
|
|
/// ACE's behavior when both timers fire in one pump.
|
|
/// </summary>
|
|
private void BuildControlDraft()
|
|
{
|
|
bool resyncDue = _sendResync
|
|
&& (_nextResyncTimestamp is null
|
|
|| _clock.GetTimestamp() > _nextResyncTimestamp.Value); // :203 (+ :47-50 immediate first send)
|
|
bool ackDue = _clock.GetTimestamp() > _nextAckTimestamp; // :211 (sendAck is always true, :54)
|
|
bool echoDue = _pendingEchoClientTime is not null; // :941 (ClientTime != -1)
|
|
if (!resyncDue && !ackDue && !echoDue)
|
|
return;
|
|
|
|
var flags = PacketHeaderFlags.None;
|
|
var writer = new PacketWriter(24);
|
|
|
|
if (ackDue)
|
|
{
|
|
flags |= PacketHeaderFlags.AckSequence; // :925-931
|
|
writer.WriteUInt32(_lastReceivedPacketSequence);
|
|
_nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks; // :215
|
|
}
|
|
|
|
if (resyncDue)
|
|
{
|
|
flags |= PacketHeaderFlags.TimeSync | PacketHeaderFlags.EncryptedChecksum; // :933-938 + :207
|
|
Span<byte> value = stackalloc byte[8];
|
|
BinaryPrimitives.WriteInt64LittleEndian(
|
|
value,
|
|
BitConverter.DoubleToInt64Bits(_clock.Seconds));
|
|
writer.WriteBytes(value);
|
|
_nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks; // :208
|
|
}
|
|
|
|
if (echoDue)
|
|
{
|
|
flags |= PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum; // :941-948 + :659
|
|
writer.WriteFloat(_pendingEchoClientTime!.Value);
|
|
writer.WriteFloat((float)_clock.Seconds - _pendingEchoClientTime.Value);
|
|
_pendingEchoClientTime = null;
|
|
}
|
|
|
|
byte[] body = writer.ToArray();
|
|
_flushQueue.Enqueue(new OutboundDraft(flags, body, OptionalLength: body.Length));
|
|
}
|
|
|
|
/// <summary>
|
|
/// SendBundle for queued game messages: groups flush in ascending group
|
|
/// order (:190-194), fragment sequences are assigned at flush time
|
|
/// (:821) starting from 0 (SessionConnectionData.FragmentSequence), and
|
|
/// the fragment Id is the constant 0x80000000 (ACE MessageFragment.cs:94).
|
|
/// One packet per message — see the class-doc simplification note.
|
|
/// </summary>
|
|
private void FlushMessageBundles()
|
|
{
|
|
if (_pendingMessages.Count == 0)
|
|
return;
|
|
|
|
foreach ((byte[] body, GameMessageGroup group) in
|
|
_pendingMessages.OrderBy(m => (int)m.Group)) // OrderBy is stable → FIFO within a group
|
|
{
|
|
MessageFragment fragment = GameMessageFragment.BuildSingleFragment(
|
|
_s2cFragmentSequence++,
|
|
group,
|
|
body);
|
|
byte[] fragmentBytes = GameMessageFragment.Serialize(fragment);
|
|
_flushQueue.Enqueue(new OutboundDraft(
|
|
PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
|
|
fragmentBytes,
|
|
OptionalLength: 0));
|
|
}
|
|
|
|
_pendingMessages.Clear();
|
|
}
|
|
|
|
/// <summary>FlushPackets, per packet (:710-735) + SendPacket (:737-752).</summary>
|
|
private void FlushOne(OutboundDraft draft)
|
|
{
|
|
bool encrypted = (draft.Flags & PacketHeaderFlags.EncryptedChecksum) != 0;
|
|
|
|
// :716-717 — the first encrypted flush re-primes the sequence to
|
|
// CurrentValue = 1, so the first encrypted S2C packet is sequence 2.
|
|
if (encrypted && _packetSequence == 0)
|
|
_packetSequence = 1;
|
|
|
|
bool isNak = (draft.Flags & PacketHeaderFlags.RequestRetransmit) != 0; // :719
|
|
|
|
// :722-725 — ack-only (EXACT flags) and NAK packets reuse the current
|
|
// sequence without incrementing; everything else takes NextValue.
|
|
uint sequence = draft.Flags == PacketHeaderFlags.AckSequence || isNak
|
|
? _packetSequence
|
|
: NextPacketSequence();
|
|
|
|
// :728 — Header.Time = (ushort)PortalYearTicks (whole seconds).
|
|
ushort time = (ushort)(long)_clock.Seconds;
|
|
|
|
// SendPacket (:743-748) — one S2C keystream word per encrypted
|
|
// packet; cleartext packets use xor 0 (ServerPacket.cs:70 makes the
|
|
// checksum additive in that case).
|
|
uint isaacXor = encrypted ? _s2cKeystream.Next() : 0u;
|
|
|
|
// :730-731 — cache sequenced packets ≥ 2 that are not NAKs. TryAdd
|
|
// semantics: an ack reusing a live sequence does not overwrite.
|
|
if (sequence >= 2u && !isNak)
|
|
{
|
|
_cachedPackets.TryAdd(sequence, new CachedS2CPacket
|
|
{
|
|
Sequence = sequence,
|
|
Flags = draft.Flags,
|
|
Time = time,
|
|
Body = draft.Body,
|
|
OptionalLength = draft.OptionalLength,
|
|
IsaacXor = isaacXor,
|
|
});
|
|
}
|
|
|
|
Emit(sequence, draft.Flags, time, draft.Body, draft.OptionalLength, isaacXor);
|
|
}
|
|
|
|
/// <summary>UIntSequence.NextValue (UIntSequence.cs:30-41): wrap max → 0.</summary>
|
|
private uint NextPacketSequence()
|
|
{
|
|
_packetSequence = _packetSequence == uint.MaxValue ? 0u : _packetSequence + 1u;
|
|
return _packetSequence;
|
|
}
|
|
|
|
/// <summary>ServerPacket.CreateReadyToSendPacket (ServerPacket.cs:46-72).</summary>
|
|
private void Emit(
|
|
uint sequence,
|
|
PacketHeaderFlags flags,
|
|
ushort time,
|
|
byte[] body,
|
|
int optionalLength,
|
|
uint isaacXor)
|
|
{
|
|
var header = new PacketHeader
|
|
{
|
|
Sequence = sequence,
|
|
Flags = flags,
|
|
Id = _serverId, // :726
|
|
Iteration = 1, // :727
|
|
Time = time,
|
|
DataSize = checked((ushort)body.Length),
|
|
};
|
|
|
|
uint payloadHash = ComputePayloadHash(body, flags, optionalLength);
|
|
uint headerHash = header.CalculateHeaderHash32();
|
|
header.Checksum = headerHash + (payloadHash ^ isaacXor); // ServerPacket.cs:70
|
|
|
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
|
header.Pack(datagram);
|
|
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
|
|
|
_sentDatagrams.Add(datagram);
|
|
_pendingOutbound.Enqueue(datagram);
|
|
}
|
|
|
|
/// <summary>ServerPacket.cs:48-62 — Hash32(data section) + Σ fragment hashes.</summary>
|
|
private static uint ComputePayloadHash(
|
|
ReadOnlySpan<byte> body,
|
|
PacketHeaderFlags flags,
|
|
int optionalLength)
|
|
{
|
|
uint hash = Hash32.Calculate(body.Slice(0, optionalLength));
|
|
if ((flags & PacketHeaderFlags.BlobFragments) == 0)
|
|
return hash;
|
|
|
|
ReadOnlySpan<byte> remaining = body.Slice(optionalLength);
|
|
while (!remaining.IsEmpty)
|
|
{
|
|
(MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining);
|
|
if (fragment is null)
|
|
throw new InvalidOperationException("the model built a malformed fragment");
|
|
hash += PacketCodec.CalculateFragmentHash32(fragment.Value);
|
|
remaining = remaining.Slice(consumed);
|
|
}
|
|
|
|
return hash;
|
|
}
|
|
|
|
/// <summary>NetworkSession.PruneCachedPackets (:251-262) — 120 s retention
|
|
/// with ACE's ushort-wrap guard expression, verbatim.</summary>
|
|
private void PruneCachedPackets()
|
|
{
|
|
_lastPruneTimestamp = _clock.GetTimestamp(); // :253
|
|
ushort currentTime = (ushort)(long)_clock.Seconds; // :255
|
|
|
|
List<uint>? removal = null;
|
|
foreach (CachedS2CPacket packet in _cachedPackets.Values)
|
|
{
|
|
// :258 — wrap guard: `(currentTime >= x.Time ? currentTime : currentTime + ushort.MaxValue) - x.Time > 120`
|
|
if ((currentTime >= packet.Time ? currentTime : currentTime + ushort.MaxValue) - packet.Time
|
|
> CachedPacketRetentionSeconds)
|
|
{
|
|
(removal ??= new List<uint>()).Add(packet.Sequence);
|
|
}
|
|
}
|
|
|
|
if (removal is null)
|
|
return;
|
|
foreach (uint sequence in removal)
|
|
_cachedPackets.Remove(sequence);
|
|
}
|
|
|
|
private void Terminate(AceTerminationReason reason)
|
|
{
|
|
IsTerminated = true;
|
|
TerminationReason = reason;
|
|
}
|
|
|
|
// =====================================================================
|
|
// Parsing — ClientPacket.Unpack (ClientPacket.cs:22-76) equivalent over
|
|
// acdream's owned wire types. Malformed datagrams are dropped silently.
|
|
// =====================================================================
|
|
private static bool TryParse(ReadOnlySpan<byte> datagram, out ParsedPacket packet)
|
|
{
|
|
packet = null!;
|
|
if (datagram.Length < PacketHeader.Size)
|
|
return false; // ClientPacket.cs:26-27
|
|
|
|
PacketHeader header = PacketHeader.Unpack(datagram);
|
|
if (header.DataSize > datagram.Length - PacketHeader.Size)
|
|
return false; // ClientPacket.cs:31-32
|
|
|
|
ReadOnlySpan<byte> body = datagram.Slice(PacketHeader.Size, header.DataSize);
|
|
var optional = new PacketHeaderOptional();
|
|
int optionalConsumed = optional.Parse(body, header.Flags);
|
|
if (optionalConsumed < 0)
|
|
return false; // ClientPacket.cs:38-39 (HeaderOptional.IsValid)
|
|
|
|
var fragments = new List<MessageFragment>();
|
|
uint fragmentHash = 0;
|
|
if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
|
|
{
|
|
// ClientPacket.ReadFragments (:54-76) + fragmentChecksum (:84-101).
|
|
ReadOnlySpan<byte> remaining = body.Slice(optionalConsumed);
|
|
while (!remaining.IsEmpty)
|
|
{
|
|
(MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining);
|
|
if (fragment is null)
|
|
return false;
|
|
fragments.Add(fragment.Value);
|
|
fragmentHash += PacketCodec.CalculateFragmentHash32(fragment.Value);
|
|
remaining = remaining.Slice(consumed);
|
|
}
|
|
}
|
|
|
|
packet = new ParsedPacket(header, optional, fragments, fragmentHash);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A parsed, CRC-verifiable C2S packet. Buffered out-of-order packets are
|
|
/// stored in THIS form — ACE never re-verifies a buffered packet's CRC
|
|
/// (the key was consumed on first arrival).
|
|
/// </summary>
|
|
private sealed record ParsedPacket(
|
|
PacketHeader Header,
|
|
PacketHeaderOptional Optional,
|
|
List<MessageFragment> Fragments,
|
|
uint FragmentHash);
|
|
|
|
private readonly record struct OutboundDraft(
|
|
PacketHeaderFlags Flags,
|
|
byte[] Body,
|
|
int OptionalLength);
|
|
|
|
/// <summary>The cached ServerPacket surrogate — see FlushOne/TryRetransmit.</summary>
|
|
private sealed class CachedS2CPacket
|
|
{
|
|
public uint Sequence;
|
|
public PacketHeaderFlags Flags;
|
|
public ushort Time;
|
|
public byte[] Body = Array.Empty<byte>();
|
|
public int OptionalLength;
|
|
public uint IsaacXor;
|
|
}
|
|
|
|
/// <summary>ACE MessageBuffer surrogate (NetworkSession.cs:495-518).</summary>
|
|
private sealed class PartialC2SMessage
|
|
{
|
|
private readonly byte[]?[] _parts;
|
|
private int _received;
|
|
|
|
public PartialC2SMessage(int totalFragments) =>
|
|
_parts = new byte[totalFragments][];
|
|
|
|
public bool Complete => _received == _parts.Length;
|
|
|
|
public void Add(int index, byte[] payload)
|
|
{
|
|
if (_parts[index] is not null)
|
|
return; // duplicate index — idempotent
|
|
_parts[index] = payload;
|
|
_received++;
|
|
}
|
|
|
|
public byte[] Assemble()
|
|
{
|
|
int total = 0;
|
|
foreach (byte[]? part in _parts)
|
|
total += part!.Length;
|
|
byte[] message = new byte[total];
|
|
int offset = 0;
|
|
foreach (byte[]? part in _parts)
|
|
{
|
|
byte[] bytes = part!;
|
|
bytes.CopyTo(message.AsSpan(offset));
|
|
offset += bytes.Length;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
}
|
|
}
|