using System.Diagnostics; namespace AcDream.Core.Net.Packets; /// /// 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 /// Sequence (the actual unique identifier — ACE's outbound fragment /// Id field is always the constant 0x80000000; the /// per-message-unique value is the Sequence, matching how ACE's /// own NetworkSession.HandleFragment keys its partialFragments dict). /// /// /// Correctness properties: /// /// Out-of-order arrival: fragments can arrive in any index order; /// the full message is released on the last fragment regardless of /// its index. /// Duplicate-fragment idempotence: receiving index N twice for the /// same Sequence is harmless — the second copy is silently ignored. /// A late duplicate of an ALREADY-COMPLETED message is dropped via /// the recently-completed ring below instead of allocating a fresh /// partial that could never complete. /// Single-fragment messages: Count=1 releases immediately on /// that one fragment with no buffering. /// Orphaned partials (Campaign N Slice N6): entries whose last /// accepted fragment is older than /// are dropped by , which /// runs on a 5 s /// cadence. N4's RejectRetransmit abandonment made an unrecoverable /// partial a REACHABLE permanent state (the server pruned a /// fragment-bearing packet from its cache and told us to stop /// asking — that blob can never complete), so the pre-N6 "buffered /// until DropAll" posture was a slow leak on a lossy link. /// /// /// /// /// Retail oracle for the eviction shape: the client's ephemeral-blob info /// table is pruned on a 5.0 s sweep gate /// (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the x87 compare /// against 5.0 at 0x0054A3DC), each entry timing out 5.0 s after its LAST /// refresh (ArrivedEphInfo::fTimedOut @ 0x0054AE30; the timestamp is /// re-stamped on every update, ArrivedEphInfo::UpdateNetBlobID /// @ 0x0054AE00). Our partial entries mirror the re-stamp-on-update /// rule; the 60 s TTL (vs retail's 5 s on its ordering-stamp table) and the /// completed-sequence ring are acdream adaptations — divergence register /// row AD-52. 60 s is a floor, not a tunable: a partial that is merely slow /// (packet-level NAK recovery in flight) must never be evicted. /// /// public sealed class FragmentAssembler { /// /// AD-52: age floor before an incomplete partial is dropped, measured /// from its last ACCEPTED fragment. Any in-flight recovery (0.6 s NAK /// cadence, ACE's 120 s S2C cache) resolves orders of magnitude faster; /// only a server-abandoned partial (RejectRetransmit) can reach it. /// Do not shrink. /// internal const double PartialTtlSeconds = 60.0; /// AD-52: how many recently-completed multi-fragment sequences /// are remembered to drop late duplicates without re-partialing. internal const int CompletedRingSize = 64; private static double DefaultNowSeconds() => (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency; private readonly Dictionary _inFlight = new(); private readonly Func _nowSeconds; // Ring of the last CompletedRingSize completed multi-fragment sequences. // _completedCount bounds the membership scan so the zero-initialized // slots can never match a real sequence 0 (ACE's fragment sequences // START at 0 — SessionConnectionData.cs:36). private readonly uint[] _completedSequences = new uint[CompletedRingSize]; private int _completedNext; private int _completedCount; public FragmentAssembler() : this(null) { } /// Test seam: injectable monotonic seconds source for the TTL /// stamps and . Production uses /// time. internal FragmentAssembler(Func? nowSeconds) => _nowSeconds = nowSeconds ?? DefaultNowSeconds; /// /// Number of logical messages currently partially-assembled (waiting on /// more fragments to arrive). /// public int PartialCount => _inFlight.Count; /// /// Ingest one fragment. If this fragment completes a message, returns /// the fully-assembled payload as a new byte array. Otherwise returns /// null and the fragment is held for later assembly. /// /// The decoded fragment from the wire. /// /// Filled with the completed message's GameMessageGroup (queue) if the /// call returns a non-null payload; otherwise 0. /// 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; } // Key on Sequence, not Id — ACE's outbound Id is a constant and // its own inbound assembler keys on Sequence for the same reason. if (!_inFlight.TryGetValue(h.Sequence, out var partial)) { // N6: a late duplicate of an already-completed message must not // allocate a fresh partial that can never complete. if (WasRecentlyCompleted(h.Sequence)) return null; partial = new PartialMessage(h.Count, h.Queue, _nowSeconds()); _inFlight[h.Sequence] = 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++; // Retail re-stamps on update (ArrivedEphInfo::UpdateNetBlobID // @ 0x0054AE00): a slow-but-alive partial never ages out. partial.LastFragmentSeconds = _nowSeconds(); } 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.Sequence); RememberCompleted(h.Sequence); messageQueue = partial.Queue; return combined; } /// /// Production borrowed-memory path. A complete single-fragment message /// is returned as a view into the current datagram. Multi-fragment /// payloads are copied only because they must survive that datagram's /// pooled lifetime. /// internal bool TryIngest( in BorrowedMessageFragment fragment, out ReadOnlyMemory message, out ushort messageQueue) { MessageFragmentHeader header = fragment.Header; message = ReadOnlyMemory.Empty; messageQueue = 0; if (header.Count == 1 && header.Index == 0) { message = fragment.Payload; messageQueue = header.Queue; return true; } if (!_inFlight.TryGetValue( header.Sequence, out PartialMessage? partial)) { // N6: drop a late duplicate of an already-completed message // instead of re-partialing it (the pre-N6 leak: the fresh // partial could never complete and lived forever). if (WasRecentlyCompleted(header.Sequence)) return false; partial = new PartialMessage( header.Count, header.Queue, _nowSeconds()); _inFlight[header.Sequence] = partial; } else if (partial.TotalFragments != header.Count || partial.Queue != header.Queue) { // Same sequence with conflicting identity is malformed. Preserve // the first accepted partial instead of corrupting its layout. return false; } if (partial.Fragments[header.Index] is null) { partial.Fragments[header.Index] = fragment.Payload.ToArray(); partial.ReceivedCount++; partial.LastFragmentSeconds = _nowSeconds(); } if (partial.ReceivedCount < partial.TotalFragments) return false; int totalBytes = 0; for (int index = 0; index < partial.TotalFragments; index++) { totalBytes += partial.Fragments[index]!.Length; } var combined = new byte[totalBytes]; int offset = 0; for (int index = 0; index < partial.TotalFragments; index++) { byte[] payload = partial.Fragments[index]!; payload.CopyTo(combined, offset); offset += payload.Length; } _inFlight.Remove(header.Sequence); RememberCompleted(header.Sequence); message = combined; messageQueue = partial.Queue; return true; } /// /// N6 age-based eviction: drop every partial whose last accepted /// fragment is older than . Called by /// on the retail 5 s /// flush cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0). /// Returns the number of partials evicted. /// internal int SweepExpired() { if (_inFlight.Count == 0) return 0; double now = _nowSeconds(); int evicted = 0; foreach ((uint sequence, PartialMessage partial) in _inFlight) { // Strictly-older-than the floor: an entry exactly 60 s old // survives (an eviction floor, never an eager cutoff). if (now - partial.LastFragmentSeconds > PartialTtlSeconds) { // Dictionary.Remove during enumeration is safe on .NET // Core 3.0+ and does not invalidate the enumerator. _inFlight.Remove(sequence); evicted++; } } return evicted; } /// Discard all in-flight partial messages. public void DropAll() => _inFlight.Clear(); private bool WasRecentlyCompleted(uint sequence) { for (int i = 0; i < _completedCount; i++) { if (_completedSequences[i] == sequence) return true; } return false; } private void RememberCompleted(uint sequence) { _completedSequences[_completedNext] = sequence; _completedNext = (_completedNext + 1) % CompletedRingSize; if (_completedCount < CompletedRingSize) _completedCount++; } private sealed class PartialMessage { public readonly byte[]?[] Fragments; public readonly int TotalFragments; public readonly ushort Queue; public int ReceivedCount; /// Seconds stamp of the last ACCEPTED fragment (creation /// stamp until one lands) — the TTL clock for /// . public double LastFragmentSeconds; public PartialMessage(int count, ushort queue, double nowSeconds) { TotalFragments = count; Fragments = new byte[count][]; Queue = queue; LastFragmentSeconds = nowSeconds; } } }