diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceCryptoModel.cs b/tests/AcDream.Core.Net.Tests/Transport/AceCryptoModel.cs
new file mode 100644
index 00000000..7dd346e0
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/AceCryptoModel.cs
@@ -0,0 +1,112 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Cryptography;
+
+namespace AcDream.Core.Net.Tests.Transport;
+
+///
+/// Faithful port of ACE's server-side C2S checksum-key discipline
+/// (references/ACE/Source/ACE.Common/Cryptography/CryptoSystem.cs)
+/// over acdream's . This is the exact machinery
+/// Coldeve uses to verify every encrypted client packet, so the double must
+/// reproduce its behavior word-for-word — in particular the 256-key search
+/// window, the parked-key set ("xors"), and the way an unexpected key
+/// permanently orphans window capacity.
+///
+///
+/// Behavior summary (all ACE, none invented):
+///
+/// - The keystream is one ISAAC word per ENCRYPTED packet, in the
+/// order the client SENT them (drew them), not arrival order.
+/// - walks forward at most
+/// − |xors| words hunting for the
+/// presented key, parking every skipped word in the xors set.
+/// - advances the wheel when the presented
+/// key is the current one, otherwise un-parks it from xors.
+/// - A key that is BEHIND the wheel (already consumed) can never be
+/// found again — searching for it burns the remaining window.
+///
+///
+///
+internal sealed class AceCryptoModel
+{
+ /// CryptoSystem.cs:8 — the 256-key search window.
+ public const int MaximumEffortLevel = 256;
+
+ private readonly IsaacRandom _keystream;
+
+ ///
+ /// CryptoSystem.cs:9 — keys the search walked past while hunting for an
+ /// out-of-order arrival, parked so the retransmission (carrying the
+ /// ORIGINAL key) can still verify.
+ ///
+ private readonly HashSet _xors = new();
+
+ /// CryptoSystem.cs:10 — the next expected keystream word.
+ public uint CurrentKey { get; private set; }
+
+ ///
+ /// CryptoSystem.cs:11-14 — seed the ISAAC wheel and pre-draw the first
+ /// key. ACE's CryptoSystem(uint seed) passes
+ /// BitConverter.GetBytes(seed) (little-endian), which we mirror.
+ ///
+ public AceCryptoModel(uint seed)
+ {
+ Span seedBytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
+ _keystream = new IsaacRandom(seedBytes);
+ CurrentKey = _keystream.Next();
+ }
+
+ ///
+ /// Remaining search capacity: 256 − |xors|. Every parked key that is
+ /// never consumed (an orphan — e.g. from a re-keyed resend) shrinks this
+ /// permanently. When it reaches zero, the next packet loss is
+ /// unrecoverable.
+ ///
+ public int Headroom => MaximumEffortLevel - _xors.Count;
+
+ ///
+ /// Number of currently parked keys. A parked key is either a pending
+ /// retransmission's original key (healthy, recovered on arrival) or a
+ /// permanent orphan (the client re-keyed the resend and the original
+ /// word will never be presented).
+ ///
+ public int OrphanCount => _xors.Count;
+
+ ///
+ /// CryptoSystem.cs:19-29 — advance the wheel if is
+ /// the current key; otherwise remove it from the parked set.
+ ///
+ public void ConsumeKey(uint x)
+ {
+ if (CurrentKey == x)
+ CurrentKey = _keystream.Next();
+ else
+ _xors.Remove(x);
+ }
+
+ ///
+ /// CryptoSystem.cs:30-49 — is the current key, a
+ /// parked key, or reachable within the remaining search window? Walking
+ /// parks every skipped word. Verbatim port including the loop bound
+ /// being captured BEFORE the walk starts.
+ ///
+ public bool Search(uint x)
+ {
+ if (CurrentKey == x)
+ return true;
+ if (_xors.Contains(x))
+ return true;
+
+ int g = _xors.Count;
+ for (int i = 0; i < MaximumEffortLevel - g; i++)
+ {
+ _xors.Add(CurrentKey);
+ ConsumeKey(CurrentKey);
+ if (CurrentKey == x)
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs
new file mode 100644
index 00000000..7ed8925e
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs
@@ -0,0 +1,959 @@
+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;
+
+///
+/// Termination causes the double can hit, mirroring ACE's
+/// SessionTerminationReason names for the modeled paths.
+///
+internal enum AceTerminationReason
+{
+ None,
+ /// NetworkSession.cs:312-315 — client sent a Disconnect header.
+ PacketHeaderDisconnect,
+ /// NetworkSession.cs:318-321 — client sent NetErrorDisconnect.
+ ClientSentNetworkErrorDisconnect,
+ /// NetworkSession.cs:393-397 — sequence gap beyond the crypto search window.
+ AbnormalSequenceReceived,
+ /// TimeoutTick (NetworkSession.cs:88, :329-331) expired — every ACE transport death is silence.
+ NetworkTimeout,
+}
+
+///
+/// Transport-free model of ACE's per-connection NetworkSession
+/// 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
+/// references/ACE/Source/ACE.Server/Network/NetworkSession.cs (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.
+///
+///
+/// Time comes exclusively from an injected — no
+/// wall clock anywhere. The model is single-threaded by design; callers
+/// (see ) serialize access.
+///
+///
+///
+/// Intentional simplifications, none affecting the pinned rules:
+///
+/// - 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 (ACE checks
+/// TimeoutTick from the WorldManager loop).
+/// - 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.
+/// - 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.
+/// - 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).
+///
+///
+///
+internal sealed class AceSessionModel
+{
+ // ---- ACE constants, cited ----
+ /// NetworkSession.cs:381 — max NAK ids per RequestRetransmit.
+ private const int MaxNumNakSeqIds = 115;
+ /// NetworkSession.cs:359 — `new TimeSpan(0, 0, 1)` NAK rate limit.
+ private static readonly long NakRateLimitTicks = TimeSpan.FromSeconds(1).Ticks;
+ /// NetworkSession.cs:32 — timeBetweenAck = 2000 ms.
+ private static readonly long AckIntervalTicks = TimeSpan.FromSeconds(2).Ticks;
+ /// NetworkSession.cs:31 — timeBetweenTimeSync = 20000 ms.
+ private static readonly long TimeSyncIntervalTicks = TimeSpan.FromSeconds(20).Ticks;
+ /// NetworkManager.DefaultSessionTimeout (60 s), applied at NetworkSession.cs:329-331.
+ private static readonly long SessionTimeoutTicks = TimeSpan.FromSeconds(60).Ticks;
+ /// NetworkSession.cs:67 — cachedPacketPruneInterval = 5 s.
+ private static readonly long CachePruneIntervalTicks = TimeSpan.FromSeconds(5).Ticks;
+ /// NetworkSession.cs:72 — cachedPacketRetentionTime = 120 s.
+ 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;
+
+ /// C2S verifier — SessionConnectionData.CryptoClient (SessionConnectionData.cs:61).
+ public AceCryptoModel Crypto { get; }
+
+ /// S2C keystream — SessionConnectionData.IssacServer (SessionConnectionData.cs:62).
+ private readonly IsaacRandom _s2cKeystream;
+
+ // ---- receive state ----
+ /// NetworkSession.cs:57 — starts at 1.
+ private uint _lastReceivedPacketSequence = 1;
+ /// NetworkSession.cs:58 — starts at 0.
+ private uint _lastReceivedFragmentSequence;
+ /// NetworkSession.cs:41 — outOfOrderPackets (parsed + CRC-verified; never re-verified).
+ private readonly Dictionary _outOfOrderPackets = new();
+ /// NetworkSession.cs:42 — partialFragments (multi-fragment C2S reassembly).
+ private readonly Dictionary _partialFragments = new();
+ /// NetworkSession.cs:43 — outOfOrderFragments (the C2S fragment gate buffer).
+ private readonly Dictionary _outOfOrderFragments = new();
+ /// NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null).
+ private long? _lastNakTimestamp;
+ private float? _pendingEchoClientTime;
+
+ // ---- send state ----
+ ///
+ /// 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.
+ ///
+ private uint _packetSequence = uint.MaxValue;
+ /// SessionConnectionData.FragmentSequence — default 0; assigned at bundle flush (NetworkSession.cs:821).
+ private uint _s2cFragmentSequence;
+ /// NetworkSession.cs:65 — cachedPackets, keyed by sequence.
+ private readonly Dictionary _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();
+ /// NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update.
+ private readonly Queue _flushQueue = new();
+
+ // ---- observable outputs ----
+ private readonly List _dispatchedMessages = new();
+ private readonly List _sentDatagrams = new();
+ private readonly Queue _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 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;
+ /// Fully-assembled C2S message bodies in ACE dispatch order.
+ public IReadOnlyList DispatchedMessages => _dispatchedMessages;
+ /// Every S2C datagram the model has emitted, in send order (cumulative).
+ public IReadOnlyList SentDatagrams => _sentDatagrams;
+ public bool IsTerminated { get; private set; }
+ public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None;
+ /// VirtualClock timestamp past which terminates the session.
+ public long TimeoutDeadlineTimestamp { get; private set; }
+ public int OutOfOrderPacketCount => _outOfOrderPackets.Count;
+ /// Completed messages parked behind the C2S fragment gate (NetworkSession.cs:539-542).
+ public int FragmentGateBufferCount => _outOfOrderFragments.Count;
+ public int PartialFragmentBufferCount => _partialFragments.Count;
+ public int CachedPacketCount => _cachedPackets.Count;
+ public IReadOnlyCollection CachedPacketSequences => _cachedPackets.Keys;
+ /// Packets silently dropped by CRC/Search failure (NetworkSession.cs:277-280).
+ public int CrcDropCount { get; private set; }
+ /// Packets dropped by the duplicate-rejection rule (NetworkSession.cs:342-347).
+ public int DuplicateDropCount { get; private set; }
+ public int RetransmitsServed { get; private set; }
+
+ // ---- script hooks for FakeAceTransport ----
+ /// Fired when a LoginRequest packet is handled (NetworkSession.cs:463-468).
+ public event Action? LoginRequestReceived;
+ /// Fired when a cookie-matching ConnectResponse is accepted (NetworkManager.cs:50-79).
+ public event Action? ConnectResponseAccepted;
+ /// Fired per dispatched C2S message body, in ACE dispatch order.
+ public event Action? MessageDispatched;
+
+ /// Drain the datagrams emitted since the last call, in send order.
+ public List TakePendingDatagrams()
+ {
+ var drained = new List(_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 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? uncached = null;
+ foreach (uint sequence in packet.Optional.RetransmitRequests)
+ {
+ if (!TryRetransmit(sequence))
+ (uncached ??= new List()).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();
+ }
+
+ /// ClientPacket.VerifyCRC (ClientPacket.cs:138-163) over the crypto model.
+ 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;
+ }
+
+ ///
+ /// NetworkManager.cs:50-79 — ConnectResponse routing. The double only
+ /// supports the exact shape retail/acdream sends (flags ==
+ /// ConnectResponse alone, 8-byte cookie body).
+ ///
+ 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();
+ }
+
+ /// NetworkSession.HandleOrderedPacket (:435-477).
+ 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;
+ }
+ }
+
+ /// NetworkSession.ProcessFragment (:483-544).
+ 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);
+ }
+
+ /// NetworkSession.HandleFragment (:550-554).
+ private void HandleFragment(byte[] message)
+ {
+ _dispatchedMessages.Add(message);
+ MessageDispatched?.Invoke(message);
+ _lastReceivedFragmentSequence++;
+ }
+
+ /// NetworkSession.CheckOutOfOrderPackets (:559-566).
+ private void CheckOutOfOrderPackets()
+ {
+ while (_outOfOrderPackets.Remove(_lastReceivedPacketSequence + 1, out ParsedPacket? packet))
+ HandleOrderedPacket(packet);
+ }
+
+ /// NetworkSession.CheckOutOfOrderFragments (:571-578).
+ private void CheckOutOfOrderFragments()
+ {
+ while (_outOfOrderFragments.Remove(_lastReceivedFragmentSequence + 1, out byte[]? message))
+ HandleFragment(message);
+ }
+
+ /// NetworkSession.AcknowledgeSequence (:663-673) — prune strictly-older
+ /// cached S2C packets. Raw uint compare (`x < sequence`), NOT wrap-safe:
+ /// modeled exactly as ACE does it.
+ private void AcknowledgeSequence(uint sequence)
+ {
+ List? removal = null;
+ foreach (uint key in _cachedPackets.Keys)
+ {
+ if (key < sequence)
+ (removal ??= new List()).Add(key);
+ }
+
+ if (removal is null)
+ return;
+ foreach (uint key in removal)
+ _cachedPackets.Remove(key);
+ }
+
+ /// NetworkSession.DoRequestForRetransmission (:387-426).
+ private void DoRequestForRetransmission(uint rcvdSeq)
+ {
+ uint desiredSeq = _lastReceivedPacketSequence + 1; // :389
+ var needSeq = new List { 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
+ }
+
+ /// NetworkSession.Retransmit (:675-708) — serve a NAKed id from the cache.
+ 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;
+ }
+
+ /// 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).
+ private void EnqueueRejectRetransmit(List 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.
+ // =====================================================================
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Server-side game-message send. Flushed by the next
+ /// as its own BlobFragments|EncryptedChecksum packet (EnqueueSend sets
+ /// EncryptedChecksum, NetworkSession.cs:129).
+ ///
+ public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group) =>
+ _pendingMessages.Add((gameMessageBody, group));
+
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 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));
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ /// FlushPackets, per packet (:710-735) + SendPacket (:737-752).
+ 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);
+ }
+
+ /// UIntSequence.NextValue (UIntSequence.cs:30-41): wrap max → 0.
+ private uint NextPacketSequence()
+ {
+ _packetSequence = _packetSequence == uint.MaxValue ? 0u : _packetSequence + 1u;
+ return _packetSequence;
+ }
+
+ /// ServerPacket.CreateReadyToSendPacket (ServerPacket.cs:46-72).
+ 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);
+ }
+
+ /// ServerPacket.cs:48-62 — Hash32(data section) + Σ fragment hashes.
+ private static uint ComputePayloadHash(
+ ReadOnlySpan body,
+ PacketHeaderFlags flags,
+ int optionalLength)
+ {
+ uint hash = Hash32.Calculate(body.Slice(0, optionalLength));
+ if ((flags & PacketHeaderFlags.BlobFragments) == 0)
+ return hash;
+
+ ReadOnlySpan 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;
+ }
+
+ /// NetworkSession.PruneCachedPackets (:251-262) — 120 s retention
+ /// with ACE's ushort-wrap guard expression, verbatim.
+ private void PruneCachedPackets()
+ {
+ _lastPruneTimestamp = _clock.GetTimestamp(); // :253
+ ushort currentTime = (ushort)(long)_clock.Seconds; // :255
+
+ List? 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()).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 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 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();
+ uint fragmentHash = 0;
+ if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
+ {
+ // ClientPacket.ReadFragments (:54-76) + fragmentChecksum (:84-101).
+ ReadOnlySpan 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;
+ }
+
+ ///
+ /// 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).
+ ///
+ private sealed record ParsedPacket(
+ PacketHeader Header,
+ PacketHeaderOptional Optional,
+ List Fragments,
+ uint FragmentHash);
+
+ private readonly record struct OutboundDraft(
+ PacketHeaderFlags Flags,
+ byte[] Body,
+ int OptionalLength);
+
+ /// The cached ServerPacket surrogate — see FlushOne/TryRetransmit.
+ private sealed class CachedS2CPacket
+ {
+ public uint Sequence;
+ public PacketHeaderFlags Flags;
+ public ushort Time;
+ public byte[] Body = Array.Empty();
+ public int OptionalLength;
+ public uint IsaacXor;
+ }
+
+ /// ACE MessageBuffer surrogate (NetworkSession.cs:495-518).
+ 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;
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs
new file mode 100644
index 00000000..64526ac0
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs
@@ -0,0 +1,668 @@
+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;
+
+///
+/// Tests OF the ACE-behaviour double — they pin the model against the ACE
+/// source rules cited inside so slices N1-N5
+/// can trust it as the referee. They do not test acdream production code.
+///
+public sealed class AceSessionModelTests
+{
+ private const uint ClientSeed = 0x11AA22BBu;
+ private const uint ServerSeed = 0x33CC44DDu;
+ private const uint ClientId = 0x1234u;
+ private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
+
+ [Fact]
+ public void Nak_FiresOnlyAtDesiredPlusTwo_WithOneSecondRateLimit()
+ {
+ (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
+ byte[][] packets = BuildSequentialPackets(client, count: 5); // seq 2..6
+
+ // Gap of one: desired = 2, arrived = 3 → desired+2 (4) > 3 → buffered, NO NAK
+ // (NetworkSession.cs:351-363 — ACE needs two arrivals past the gap).
+ model.Receive(packets[1]);
+ model.Update();
+ Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
+ Assert.Equal(1, model.OutOfOrderPacketCount);
+ Assert.Equal(1u, model.LastReceivedPacketSequence);
+
+ // Second arrival past the gap: desired+2 (4) <= 4 → NAK fires, cleartext,
+ // flags exactly RequestRetransmit, listing only the truly missing id.
+ model.Receive(packets[2]);
+ model.Update();
+ byte[] nak = Assert.Single(
+ OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
+ Assert.Equal(new uint[] { 2u }, NakIds(nak));
+
+ // Within the 1 s limit (:359) another eligible arrival does NOT re-NAK.
+ model.Receive(packets[3]);
+ model.Update();
+ Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
+
+ // Limiter reopens strictly after 1 s.
+ clock.Advance(TimeSpan.FromSeconds(1.1));
+ model.Receive(packets[4]);
+ model.Update();
+ byte[] second = Assert.Single(
+ OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
+ Assert.Equal(new uint[] { 2u }, NakIds(second));
+ }
+
+ [Fact]
+ public void ValidResend_IsAccepted_AndOrderingRestored()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ byte[][] packets = BuildSequentialPackets(client, 3); // seq 2(w1), 3(w2), 4(w3)
+
+ model.Receive(packets[0]); // in order
+ model.Receive(packets[2]); // out of order: Search parks w2, consumes w3
+ Assert.Single(model.DispatchedMessages);
+ Assert.Equal(255, model.Crypto.Headroom);
+
+ // A CORRECT retransmission is byte-identical (same sequence, same
+ // keystream word). The parked key verifies it (CryptoSystem.cs:36-39)
+ // and ConsumeKey un-parks it — the window fully recovers, and the
+ // buffered packet replays in order (NetworkSession.cs:559-566).
+ model.Receive(packets[1]);
+ Assert.Equal(new byte[] { 2, 3, 4 }, Markers(model));
+ Assert.Equal(4u, model.LastReceivedPacketSequence);
+ Assert.Equal(0, model.OutOfOrderPacketCount);
+ Assert.Equal(256, model.Crypto.Headroom);
+ Assert.Equal(0, model.Crypto.OrphanCount);
+ }
+
+ [Fact]
+ public void ReKeyedResend_PermanentlyOrphansAKeystreamWord()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ byte[][] packets = BuildSequentialPackets(client, 3); // seq 2(w1), 3(w2), 4(w3)
+
+ model.Receive(packets[0]);
+ model.Receive(packets[2]); // parks w2 for the pending retransmission
+ Assert.Equal(255, model.Crypto.Headroom);
+
+ // The buggy client re-keys the resend of seq 3: a fresh encode draws
+ // w4 — which is exactly the server's CurrentKey (the gap walk mirrored
+ // the client's consumption), so ACE ACCEPTS the packet... but the
+ // parked ORIGINAL w2 is now orphaned: no future packet will ever
+ // present it, and the 256-key window is one slot smaller FOREVER.
+ // This is campaign doc §3 row 2 / landmine #2: NEVER re-key a resend —
+ // every loss+re-key cycle burns another slot until the window is gone.
+ byte[] rekeyed = client.BuildGameMessagePacket(
+ packetSequence: 3,
+ fragmentSequence: 2,
+ MakeMessage(3));
+ model.Receive(rekeyed);
+ Assert.Equal(new byte[] { 2, 3, 4 }, Markers(model)); // accepted, ordering restored
+ Assert.Equal(255, model.Crypto.Headroom);
+ Assert.Equal(1, model.Crypto.OrphanCount);
+
+ // Healthy follow-on traffic never recovers the orphan.
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(5))); // seq 5
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(6))); // seq 6
+ Assert.Equal(new byte[] { 2, 3, 4, 5, 6 }, Markers(model));
+ Assert.Equal(255, model.Crypto.Headroom);
+ Assert.Equal(1, model.Crypto.OrphanCount);
+ }
+
+ [Fact]
+ public void ResendOfAlreadyAcceptedPacket_BurnsTheSearchWindow()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ byte[][] packets = BuildSequentialPackets(client, 2); // seq 2(w1), 3(w2)
+
+ model.Receive(packets[0]); // accepted — w1 consumed, wheel at w2
+ Assert.Single(model.DispatchedMessages);
+
+ // An UNREQUESTED duplicate of an already-accepted packet: VerifyCRC
+ // runs BEFORE dedup (NetworkSession.cs:277 vs :342), and w1 is now
+ // BEHIND the wheel — Search walks the entire remaining window
+ // (parking all 256 keys) and fails. Silent drop, window at zero.
+ // Campaign doc §3 row 2 / landmine #3: never resend unrequested.
+ model.Receive(packets[0]);
+ Assert.Equal(1, model.CrcDropCount);
+ Assert.Single(model.DispatchedMessages);
+ Assert.Equal(0, model.Crypto.Headroom);
+ Assert.Equal(256, model.Crypto.OrphanCount);
+
+ // ACE's parked set doubles as the recovery path: the next healthy
+ // packet's key (w2) was parked during the walk, so it still verifies
+ // and un-parks — the window drains back one packet at a time.
+ model.Receive(packets[1]);
+ Assert.Equal(2, model.DispatchedMessages.Count);
+ Assert.Equal(1, model.Crypto.Headroom);
+ }
+
+ [Fact]
+ public void AckOnlyPacketAtSameSequence_AcceptedWithoutAdvancingWatermark()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ // The negotiated model has one cached S2C packet: the immediate
+ // first TimeSync at sequence 2.
+ Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray());
+
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // client seq 2 → watermark 2
+ Assert.Equal(2u, model.LastReceivedPacketSequence);
+
+ model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue);
+ model.Update(); // flushes as S2C sequence 3, cached
+ Assert.Equal(2, model.CachedPacketCount);
+
+ // acdream's acks reuse the last issued client sequence, so they land
+ // AT the watermark: accepted via the exact-equality exemption
+ // (NetworkSession.cs:342-343), the ack VALUE prunes the S2C cache
+ // strictly below it (:663-673), and the watermark does NOT advance
+ // (:474-476: Flags == AckSequence exactly).
+ model.Receive(client.BuildCleartextAck(headerSequence: 2, ackValue: 3));
+ Assert.Equal(0, model.DuplicateDropCount);
+ Assert.Equal(2u, model.LastReceivedPacketSequence);
+ Assert.Equal(new uint[] { 3u }, model.CachedPacketSequences.ToArray());
+
+ // Repeatable at the same sequence.
+ model.Receive(client.BuildCleartextAck(2, 4));
+ Assert.Equal(0, model.DuplicateDropCount);
+ Assert.Empty(model.CachedPacketSequences);
+ Assert.Equal(2u, model.LastReceivedPacketSequence);
+
+ // The exemption is equality, not <=: an ack at an OLDER sequence is
+ // rejected as a duplicate.
+ model.Receive(client.BuildCleartextAck(1, 4));
+ Assert.Equal(1, model.DuplicateDropCount);
+ }
+
+ [Fact]
+ public void CleartextNonAckAdvancesWatermark_TheAceHole()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ byte[][] packets = BuildSequentialPackets(client, 2); // seq 2(w1), 3(w2)
+ model.Receive(packets[0]); // watermark 2
+
+ // THE ACE HOLE (campaign doc §3 row 3, NetworkSession.cs:474-476):
+ // the watermark advances for ANY packet whose flags are not exactly
+ // AckSequence — including a cleartext control packet (here an
+ // EchoRequest keepalive) that reuses a live sequence number.
+ model.Receive(client.BuildCleartextEchoRequest(headerSequence: 3, clientTime: 1.5f));
+ Assert.Equal(3u, model.LastReceivedPacketSequence);
+
+ // The REAL packet at sequence 3 arrives: its CRC verifies (the
+ // keystream stays aligned — the word is consumed properly), but the
+ // dedup stage (:342-347) drops the payload. The message is gone
+ // FOREVER and ACE will never NAK it — the self-induced wedge that
+ // forbids standalone non-ack control packets (register AP-125/TS-58).
+ model.Receive(packets[1]);
+ Assert.Equal(1, model.DuplicateDropCount);
+ Assert.Single(model.DispatchedMessages);
+ Assert.Equal(3u, model.LastReceivedPacketSequence);
+ Assert.Equal(256, model.Crypto.Headroom); // no orphan — the loss is pure payload
+ }
+
+ [Fact]
+ public void FragmentGate_StallsOnGap_AndHealsWhenMissingFragmentArrives()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ // Three in-order PACKETS carrying out-of-order FRAGMENT sequences:
+ // packet 2 → fragment 1, packet 3 → fragment 3, packet 4 → fragment 2.
+ // This isolates the C2S fragment gate (NetworkSession.cs:532-543)
+ // from packet-level reordering. (The packet-retransmission flavor of
+ // the heal is covered by ValidResend_IsAccepted_AndOrderingRestored.)
+ byte[] first = client.BuildGameMessagePacket(2, 1, MakeMessage(1));
+ byte[] third = client.BuildGameMessagePacket(3, 3, MakeMessage(3));
+ byte[] second = client.BuildGameMessagePacket(4, 2, MakeMessage(2));
+
+ model.Receive(first);
+ Assert.Equal(new byte[] { 1 }, Markers(model));
+
+ // The packet is accepted (in order at the packet level) but the
+ // completed message stalls silently behind the gate.
+ model.Receive(third);
+ Assert.Equal(3u, model.LastReceivedPacketSequence);
+ Assert.Equal(new byte[] { 1 }, Markers(model));
+ Assert.Equal(1, model.FragmentGateBufferCount);
+ Assert.Equal(1u, model.LastReceivedFragmentSequence);
+
+ // The missing fragment arrives (here aboard the next packet — on a
+ // real link, via packet retransmission): the gate dispatches it and
+ // drains the parked fragment in order (:571-578).
+ model.Receive(second);
+ Assert.Equal(new byte[] { 1, 2, 3 }, Markers(model));
+ Assert.Equal(0, model.FragmentGateBufferCount);
+ Assert.Equal(3u, model.LastReceivedFragmentSequence);
+ }
+
+ [Fact]
+ public void SixtySecondTimeout_Terminates_AndCleartextNaksDoNotRefreshIt()
+ {
+ (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // refresh → +60 s (:329-331)
+
+ clock.Advance(TimeSpan.FromSeconds(59));
+ // A cleartext NAK is handled and RETURNS before the timeout refresh
+ // (:283-308) — it does NOT extend the deadline. (Id 2 is the cached
+ // initial TimeSync, so this one is served, proving the path ran.)
+ model.Receive(client.BuildCleartextNak(2, 2u));
+ Assert.Equal(1, model.RetransmitsServed);
+ model.Update();
+ Assert.False(model.IsTerminated);
+
+ clock.Advance(TimeSpan.FromSeconds(2)); // 61 s since the last real packet
+ model.TakePendingDatagrams();
+ model.Update();
+ Assert.True(model.IsTerminated);
+ Assert.Equal(AceTerminationReason.NetworkTimeout, model.TerminationReason);
+ // Every ACE transport death is silence — no disconnect packet is sent.
+ Assert.Empty(model.TakePendingDatagrams());
+ }
+
+ [Fact]
+ public void GapBeyondSearchWindow_TerminatesAbnormalSequenceReceived()
+ {
+ // Boundary: watermark 1 → desired 2 → bottom 3. Arrived 259 keeps
+ // rcvd − bottom == 256 (not > MaximumEffortLevel) → a NAK capped at
+ // 115 ids (NetworkSession.cs:381, :398-410).
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ model.Receive(client.BuildGameMessagePacket(259, 1, MakeMessage(1)));
+ Assert.False(model.IsTerminated);
+ model.Update();
+ byte[] nak = Assert.Single(
+ OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
+ uint[] ids = NakIds(nak);
+ Assert.Equal(115, ids.Length);
+ Assert.Equal(2u, ids[0]); // desiredSeq leads the list (:390-391)
+ Assert.Equal(116u, ids[^1]); // then 3..116 — the 115-id cap
+
+ // One past the window: rcvd − bottom > 256 → AbnormalSequenceReceived
+ // (:393-397), and no NAK goes out.
+ (AceSessionModel model2, TestAcClient client2, _) = CreateNegotiatedModel();
+ model2.Receive(client2.BuildGameMessagePacket(260, 1, MakeMessage(1)));
+ Assert.True(model2.IsTerminated);
+ Assert.Equal(AceTerminationReason.AbnormalSequenceReceived, model2.TerminationReason);
+ model2.Update();
+ Assert.Empty(OfExactFlags(model2.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
+ }
+
+ [Fact]
+ public void Retransmit_ServesCachedBytes_WithRetransmissionFlag_AndNoNewIsaacWord()
+ {
+ (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
+ // Shadow the S2C keystream: word 1 went to the immediate TimeSync the
+ // negotiation helper drained.
+ IsaacRandom shadow = MakeIsaac(ServerSeed);
+ uint w1 = shadow.Next();
+ uint w2 = shadow.Next();
+ uint w3 = shadow.Next();
+ uint w4 = shadow.Next();
+ Assert.NotEqual(w1, w2); // sanity on the shadow itself
+
+ model.EnqueueGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
+ model.Update();
+ byte[] packetA = Assert.Single(model.TakePendingDatagrams());
+ Assert.Equal(3u, Head(packetA).Sequence); // TimeSync took 2; UIntSequence increments
+ Assert.Equal(w2, ExtractIsaacKey(packetA));
+
+ model.EnqueueGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue);
+ model.Update();
+ byte[] packetB = Assert.Single(model.TakePendingDatagrams());
+ Assert.Equal(w3, ExtractIsaacKey(packetB));
+
+ // Cleartext NAK for sequence 3 → IMMEDIATE retransmit from the cache
+ // (NetworkSession.cs:675-686): Retransmission OR'd into the flags,
+ // body bytes untouched, ORIGINAL keystream word reused, Time kept.
+ model.Receive(client.BuildCleartextNak(2, 3u));
+ byte[] resent = Assert.Single(model.TakePendingDatagrams());
+ PacketHeader resentHeader = Head(resent);
+ Assert.Equal(3u, resentHeader.Sequence);
+ Assert.Equal(
+ PacketHeaderFlags.Retransmission
+ | PacketHeaderFlags.EncryptedChecksum
+ | PacketHeaderFlags.BlobFragments,
+ resentHeader.Flags);
+ Assert.Equal(
+ packetA.AsSpan(PacketHeader.Size).ToArray(),
+ resent.AsSpan(PacketHeader.Size).ToArray());
+ Assert.Equal(w2, ExtractIsaacKey(resent));
+ Assert.Equal(Head(packetA).Time, resentHeader.Time);
+ Assert.Equal(1, model.RetransmitsServed);
+
+ // The S2C keystream was not disturbed: the next fresh packet uses w4.
+ model.EnqueueGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue);
+ model.Update();
+ byte[] packetC = Assert.Single(model.TakePendingDatagrams());
+ Assert.Equal(w4, ExtractIsaacKey(packetC));
+
+ // A NAK for an id that was never cached → RejectRetransmit (:299-304).
+ model.Receive(client.BuildCleartextNak(2, 40u));
+ model.Update();
+ byte[] reject = Assert.Single(
+ model.TakePendingDatagrams(),
+ d => (Head(d).Flags & PacketHeaderFlags.RejectRetransmit) != 0);
+ Assert.Equal(new uint[] { 40u }, RejectIds(reject));
+ }
+
+ [Fact]
+ public void CumulativeAck_EveryTwoSeconds_CleartextExactFlags_ReusedSequence()
+ {
+ (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(2)));
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); // watermark 3
+ model.Update();
+ Assert.Empty(model.TakePendingDatagrams()); // 2 s gate not due (:55, :211)
+
+ clock.Advance(TimeSpan.FromSeconds(2.1));
+ model.Update();
+ byte[] ack = Assert.Single(model.TakePendingDatagrams());
+ PacketHeader ackHeader = Head(ack);
+ // Cleartext, flags EXACTLY AckSequence (:925-931), sequence REUSED —
+ // the ack borrows the current S2C sequence without incrementing
+ // (:722-723; the initial TimeSync holds sequence 2).
+ Assert.Equal(PacketHeaderFlags.AckSequence, ackHeader.Flags);
+ Assert.Equal(2u, ackHeader.Sequence);
+ Assert.Equal(
+ 3u,
+ BinaryPrimitives.ReadUInt32LittleEndian(ack.AsSpan(PacketHeader.Size)));
+
+ model.Update(); // gate re-armed (:215) — no second ack
+ Assert.Empty(model.TakePendingDatagrams());
+
+ // The ack really did not consume a sequence: the next message takes 3.
+ model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue);
+ model.Update();
+ Assert.Equal(3u, Head(Assert.Single(model.TakePendingDatagrams())).Sequence);
+ }
+
+ [Fact]
+ public void EchoRequest_GetsEchoResponse()
+ {
+ (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
+ model.Receive(client.BuildCleartextEchoRequest(headerSequence: 2, clientTime: 5.5f));
+ clock.Advance(TimeSpan.FromSeconds(0.5));
+ model.Update();
+ // FlagEcho (:440-443, :650-661) → EchoResponse on the next control
+ // flush (:941-948): float clientTime + float (serverNow − clientTime),
+ // EncryptedChecksum forced.
+ byte[] echo = Assert.Single(model.TakePendingDatagrams());
+ Assert.Equal(
+ PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum,
+ Head(echo).Flags);
+ Assert.Equal(
+ 5.5f,
+ BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size)));
+ Assert.Equal(
+ 0.5f - 5.5f,
+ BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size + 4)));
+ }
+
+ [Fact]
+ public void CachedPackets_PruneAfter120Seconds_ThenStaleNakGetsRejectRetransmit()
+ {
+ (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
+ Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray()); // the t=0 TimeSync
+
+ // Keep the session alive across 121 s with periodic client packets
+ // (each refreshes the 60 s deadline) but no server pumps.
+ clock.Advance(TimeSpan.FromSeconds(50));
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(2)));
+ clock.Advance(TimeSpan.FromSeconds(50));
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(3)));
+ clock.Advance(TimeSpan.FromSeconds(21));
+ model.Receive(client.BuildGameMessagePacket(MakeMessage(4)));
+
+ model.Update(); // prune (:251-262): the seq-2 packet is 121 s old (> 120)
+ Assert.DoesNotContain(2u, model.CachedPacketSequences);
+
+ // A stale NAK for the pruned id → RejectRetransmit — the §3 row
+ // "S2C cache prunes at 120 s; old NAKs get RejectRetransmit".
+ model.Receive(client.BuildCleartextNak(4, 2u));
+ model.Update();
+ byte[] reject = Assert.Single(
+ model.TakePendingDatagrams(),
+ d => (Head(d).Flags & PacketHeaderFlags.RejectRetransmit) != 0);
+ Assert.Equal(new uint[] { 2u }, RejectIds(reject));
+ Assert.Equal(0, model.RetransmitsServed);
+ }
+
+ [Fact]
+ public void ConnectRequest_MatchesNegotiationFixtureLayout()
+ {
+ var clock = new VirtualClock();
+ var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie);
+ model.LoginRequestReceived += model.SendConnectRequest;
+ model.Receive(BuildLoginRequest());
+ model.Update();
+
+ // The 32-byte optional layout must match what WorldSession.Connect
+ // parses (and what WorldSessionNegotiationShutdownTests.
+ // BuildConnectRequest pins): serverTime, cookie, clientId,
+ // serverSeed, clientSeed, padding.
+ byte[] connectRequest = Assert.Single(model.TakePendingDatagrams());
+ PacketCodec.PacketDecodeResult decoded =
+ PacketCodec.TryDecode(connectRequest, inboundIsaac: null);
+ Assert.True(decoded.IsOk, decoded.Error.ToString());
+ Packet packet = decoded.Packet!;
+ Assert.True(packet.Header.HasFlag(PacketHeaderFlags.ConnectRequest));
+ Assert.Equal(0u, packet.Header.Sequence); // first NextValue of the unprimed UIntSequence
+ Assert.Equal((ushort)1, packet.Header.Iteration);
+ Assert.Equal(Cookie, packet.Optional.ConnectRequestCookie);
+ Assert.Equal(ClientId, packet.Optional.ConnectRequestClientId);
+ Assert.Equal(ServerSeed, packet.Optional.ConnectRequestServerSeed);
+ Assert.Equal(ClientSeed, packet.Optional.ConnectRequestClientSeed);
+ }
+
+ // =====================================================================
+ // Fixture helpers
+ // =====================================================================
+
+ ///
+ /// A model with the handshake completed the way a real session does it:
+ /// LoginRequest → ConnectRequest (flushed + discarded; primes the S2C
+ /// sequence to 0) → ConnectResponse → the immediate first TimeSync
+ /// (flushed + discarded; S2C sequence 2, S2C keystream word 1, cached).
+ ///
+ private static (AceSessionModel Model, TestAcClient Client, VirtualClock Clock)
+ CreateNegotiatedModel()
+ {
+ var clock = new VirtualClock();
+ var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie);
+ model.LoginRequestReceived += model.SendConnectRequest;
+
+ model.Receive(BuildLoginRequest());
+ model.Update();
+ model.TakePendingDatagrams(); // discard the ConnectRequest (sequence 0)
+
+ model.Receive(BuildConnectResponse());
+ model.Update();
+ model.TakePendingDatagrams(); // discard the immediate first TimeSync (sequence 2)
+
+ return (model, new TestAcClient(ClientSeed), clock);
+ }
+
+ private static byte[] BuildLoginRequest()
+ {
+ byte[] payload = LoginRequest.Build("testaccount", "testpassword", 1234);
+ return PacketCodec.Encode(
+ new PacketHeader { Flags = PacketHeaderFlags.LoginRequest },
+ payload,
+ outboundIsaac: null);
+ }
+
+ private static byte[] BuildConnectResponse()
+ {
+ byte[] body = new byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(body, Cookie);
+ return PacketCodec.Encode(
+ new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse },
+ body,
+ outboundIsaac: null);
+ }
+
+ /// Sequential post-handshake game-message packets: sequences 2..,
+ /// fragment sequences 1.., one keystream word each, marker = index + 2.
+ private static byte[][] BuildSequentialPackets(TestAcClient client, int count) =>
+ Enumerable.Range(0, count)
+ .Select(i => client.BuildGameMessagePacket(MakeMessage((byte)(i + 2))))
+ .ToArray();
+
+ /// An 8-byte message body whose first byte is a test marker.
+ private static byte[] MakeMessage(byte marker) =>
+ new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 };
+
+ private static byte MessageMarker(byte[] messageBody) => messageBody[0];
+
+ private static byte[] Markers(AceSessionModel model) =>
+ model.DispatchedMessages.Select(MessageMarker).ToArray();
+
+ private static PacketHeader Head(byte[] datagram) => PacketHeader.Unpack(datagram);
+
+ private static List OfExactFlags(
+ IEnumerable datagrams,
+ PacketHeaderFlags flags) =>
+ datagrams.Where(d => Head(d).Flags == flags).ToList();
+
+ private static uint[] NakIds(byte[] nakDatagram)
+ {
+ PacketCodec.PacketDecodeResult decoded =
+ PacketCodec.TryDecode(nakDatagram, inboundIsaac: null);
+ Assert.True(decoded.IsOk, decoded.Error.ToString());
+ return decoded.Packet!.Optional.RetransmitRequests.ToArray();
+ }
+
+ /// RejectRetransmit body: u32 count + ids (PacketRejectRetransmit.cs:7-17).
+ private static uint[] RejectIds(byte[] rejectDatagram)
+ {
+ ReadOnlySpan body = rejectDatagram.AsSpan(PacketHeader.Size);
+ uint count = BinaryPrimitives.ReadUInt32LittleEndian(body);
+ var ids = new uint[count];
+ for (int i = 0; i < ids.Length; i++)
+ ids[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4 + i * 4));
+ return ids;
+ }
+
+ ///
+ /// Recover the ISAAC word from an encrypted datagram's checksum:
+ /// key = (checksum − headerHash) ^ payloadHash (ClientPacket.cs:142).
+ /// Returns 0 for cleartext packets.
+ ///
+ private static uint ExtractIsaacKey(byte[] datagram)
+ {
+ PacketHeader header = PacketHeader.Unpack(datagram);
+ ReadOnlySpan body = datagram.AsSpan(PacketHeader.Size, header.DataSize);
+ var optional = new PacketHeaderOptional();
+ int consumed = optional.Parse(body, header.Flags);
+ Assert.True(consumed >= 0);
+ uint payloadHash = optional.CalculateHash32();
+ if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
+ {
+ ReadOnlySpan remaining = body.Slice(consumed);
+ while (!remaining.IsEmpty)
+ {
+ (MessageFragment? fragment, int fragmentBytes) =
+ MessageFragment.TryParse(remaining);
+ Assert.NotNull(fragment);
+ payloadHash += PacketCodec.CalculateFragmentHash32(fragment!.Value);
+ remaining = remaining.Slice(fragmentBytes);
+ }
+ }
+
+ return (header.Checksum - header.CalculateHeaderHash32()) ^ payloadHash;
+ }
+
+ private static IsaacRandom MakeIsaac(uint seed)
+ {
+ Span seedBytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
+ return new IsaacRandom(seedBytes);
+ }
+
+ ///
+ /// The client half of the conversation: builds wire-true packets with the
+ /// same primitives WorldSession uses (GameMessageFragment +
+ /// PacketCodec.Encode), drawing exactly one outbound keystream word per
+ /// encrypted encode — so "loss" is simulated by building in order and
+ /// simply not delivering.
+ ///
+ private sealed class TestAcClient
+ {
+ private readonly IsaacRandom _outboundIsaac;
+
+ /// WorldSession.cs:868 — the post-handshake reliable stream starts at 2.
+ public uint PacketSequence = 2;
+
+ /// WorldSession.cs:680 — fragment sequence starts at 1.
+ public uint FragmentSequence = 1;
+
+ public TestAcClient(uint clientSeed) => _outboundIsaac = MakeIsaac(clientSeed);
+
+ public byte[] BuildGameMessagePacket(byte[] messageBody) =>
+ BuildGameMessagePacket(PacketSequence++, FragmentSequence++, messageBody);
+
+ public byte[] BuildGameMessagePacket(
+ uint packetSequence,
+ uint fragmentSequence,
+ byte[] messageBody)
+ {
+ byte[] fragment = GameMessageFragment.Serialize(
+ GameMessageFragment.BuildSingleFragment(
+ fragmentSequence,
+ GameMessageGroup.UIQueue,
+ messageBody));
+ var header = new PacketHeader
+ {
+ Sequence = packetSequence,
+ Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
+ Id = (ushort)ClientId,
+ };
+ return PacketCodec.Encode(header, fragment, _outboundIsaac);
+ }
+
+ public byte[] BuildCleartextAck(uint headerSequence, uint ackValue)
+ {
+ byte[] body = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, ackValue);
+ return PacketCodec.Encode(
+ new PacketHeader
+ {
+ Sequence = headerSequence,
+ Flags = PacketHeaderFlags.AckSequence,
+ Id = (ushort)ClientId,
+ },
+ body,
+ outboundIsaac: null);
+ }
+
+ public byte[] BuildCleartextNak(uint headerSequence, params uint[] ids)
+ {
+ byte[] body = new byte[4 + ids.Length * 4];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)ids.Length);
+ for (int i = 0; i < ids.Length; i++)
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + i * 4), ids[i]);
+ return PacketCodec.Encode(
+ new PacketHeader
+ {
+ Sequence = headerSequence,
+ Flags = PacketHeaderFlags.RequestRetransmit,
+ Id = (ushort)ClientId,
+ },
+ body,
+ outboundIsaac: null);
+ }
+
+ public byte[] BuildCleartextEchoRequest(uint headerSequence, float clientTime)
+ {
+ byte[] body = new byte[4];
+ BinaryPrimitives.WriteSingleLittleEndian(body, clientTime);
+ return PacketCodec.Encode(
+ new PacketHeader
+ {
+ Sequence = headerSequence,
+ Flags = PacketHeaderFlags.EchoRequest,
+ Id = (ushort)ClientId,
+ },
+ body,
+ outboundIsaac: null);
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs
new file mode 100644
index 00000000..3b92b600
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs
@@ -0,0 +1,219 @@
+using System.Buffers.Binary;
+using System.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests.Transport;
+
+///
+/// An that binds a REAL
+/// to an through a
+/// — no sockets anywhere. Outbound sends run through
+/// the link into the model; the model's emitted datagrams run through the
+/// link into the queue that Receive/ReceiveAsync serve.
+///
+///
+/// The handshake is scripted against the model's events so a test can run a
+/// genuine Connect() / EnterWorld() / Tick() /
+/// Dispose() lifecycle:
+///
+/// - LoginRequest → the model answers with a ConnectRequest carrying
+/// the 32-byte optional (server time, cookie, client id, both ISAAC
+/// seeds) — the same layout the existing negotiation fixture
+/// (WorldSessionNegotiationShutdownTests.BuildConnectRequest)
+/// pins.
+/// - ConnectResponse (cookie match) → the model enqueues a
+/// CharacterList (0xF658) with one selectable character.
+/// - CharacterEnterWorldRequest (0xF7C8) → ServerReady (0xF7DF).
+/// - CharacterLogOff (0xF653 request) → the opcode-only 0xF653
+/// confirmation, so Dispose() completes its retail graceful
+/// logout instead of burning the 35 s confirmation timeout.
+///
+/// Because the model seeds its C2S verifier and S2C keystream from the same
+/// seeds it hands out in the ConnectRequest, post-handshake encrypted
+/// traffic verifies in both directions.
+///
+///
+///
+/// Thread-safety: the model is single-threaded, so every model interaction
+/// happens under one lock. ReceiveAsync (the session's background
+/// receive loop) waits on a semaphore counting queued deliverables.
+///
+///
+internal sealed class FakeAceTransport : IWorldSessionTransport
+{
+ public const uint DefaultClientSeed = 0x2B6D6F87u;
+ public const uint DefaultServerSeed = 0x9A3C51E4u;
+ public const uint DefaultClientId = 0x1234u;
+ public const ulong DefaultCookie = 0xFEEDFACECAFEBABEUL;
+ public const uint DefaultCharacterId = 0x50000001u;
+ public const string DefaultCharacterName = "+Acdream";
+ public const string DefaultAccountName = "testaccount";
+
+ private readonly object _gate = new();
+ private readonly SemaphoreSlim _deliverable = new(0);
+ private readonly Queue _toClient = new();
+ private readonly IPEndPoint _serverEndpoint = new(IPAddress.Loopback, 9000);
+
+ public VirtualClock Clock { get; }
+ public LossyLink Link { get; }
+ public AceSessionModel Model { get; }
+
+ public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
+ {
+ Clock = clock ?? new VirtualClock();
+ Link = link ?? new LossyLink();
+ Model = new AceSessionModel(
+ Clock,
+ DefaultClientSeed,
+ DefaultServerSeed,
+ DefaultClientId,
+ DefaultCookie);
+
+ Model.LoginRequestReceived += () => Model.SendConnectRequest();
+ Model.ConnectResponseAccepted += () =>
+ Model.EnqueueGameMessage(BuildCharacterListBody(), GameMessageGroup.UIQueue);
+ Model.MessageDispatched += OnClientMessage;
+ }
+
+ private void OnClientMessage(byte[] body)
+ {
+ if (body.Length < 4)
+ return;
+ uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
+ switch (opcode)
+ {
+ case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
+ // Server replies CharacterEnterWorldServerReady (0xF7DF) —
+ // WorldSession.EnterWorld blocks on this opcode.
+ Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
+ break;
+ case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
+ // ACE echoes the opcode-only confirmation; WorldSession.Dispose
+ // waits for it before sending the transport Disconnect.
+ Model.EnqueueGameMessage(BuildOpcodeOnlyBody(CharacterLogOff.Opcode), GameMessageGroup.UIQueue);
+ break;
+ }
+ }
+
+ // ---- IWorldSessionTransport ----
+
+ public void Send(ReadOnlySpan datagram) => SendCore(datagram);
+
+ // WorldSession sends the ConnectResponse to port+1; the double serves
+ // both listeners from one model, like ACE's single-process server.
+ public void Send(IPEndPoint remote, ReadOnlySpan datagram) => SendCore(datagram);
+
+ private void SendCore(ReadOnlySpan datagram)
+ {
+ lock (_gate)
+ {
+ foreach (byte[] delivered in Link.Transmit(LinkDirection.ClientToServer, datagram))
+ Model.Receive(delivered);
+ PumpServerLocked();
+ }
+ }
+
+ ///
+ /// Run one server frame (model Update + S2C link delivery) without any
+ /// client traffic — the hook tests use after advancing the clock or
+ /// enqueuing server-side messages.
+ ///
+ public void PumpServer()
+ {
+ lock (_gate)
+ {
+ PumpServerLocked();
+ }
+ }
+
+ private void PumpServerLocked()
+ {
+ Model.Update();
+ foreach (byte[] outbound in Model.TakePendingDatagrams())
+ {
+ foreach (byte[] delivered in Link.Transmit(LinkDirection.ServerToClient, outbound))
+ {
+ _toClient.Enqueue(delivered);
+ _deliverable.Release();
+ }
+ }
+ }
+
+ public int Receive(Span destination, TimeSpan timeout, out IPEndPoint? from)
+ {
+ lock (_gate)
+ {
+ PumpServerLocked();
+ }
+
+ if (timeout < TimeSpan.Zero)
+ timeout = TimeSpan.Zero;
+ if (!_deliverable.Wait(timeout))
+ {
+ from = null;
+ return -1; // NetClient.Receive's timeout contract
+ }
+
+ from = _serverEndpoint;
+ lock (_gate)
+ {
+ byte[] datagram = _toClient.Dequeue();
+ datagram.CopyTo(destination);
+ return datagram.Length;
+ }
+ }
+
+ public async ValueTask ReceiveAsync(
+ Memory destination,
+ CancellationToken cancellationToken)
+ {
+ await _deliverable.WaitAsync(cancellationToken).ConfigureAwait(false);
+ lock (_gate)
+ {
+ byte[] datagram = _toClient.Dequeue();
+ datagram.CopyTo(destination);
+ return new NetReceiveResult(datagram.Length, _serverEndpoint);
+ }
+ }
+
+ public void Dispose()
+ {
+ // WorldSession disposes the transport only after cancelling and
+ // joining its receive task, so no waiter can be parked on the
+ // semaphore here. SemaphoreSlim without AvailableWaitHandle holds no
+ // unmanaged state — deliberately left to the GC to keep a hypothetical
+ // late waiter from hitting ObjectDisposedException.
+ }
+
+ // ---- scripted server content ----
+
+ ///
+ /// Minimal CharacterList (0xF658) matching CharacterList.Parse:
+ /// status, active characters, deleted characters, slot count, account,
+ /// turbine chat, ToD flag.
+ ///
+ private static byte[] BuildCharacterListBody()
+ {
+ var writer = new PacketWriter(96);
+ writer.WriteUInt32(CharacterList.Opcode);
+ writer.WriteUInt32(0); // status
+ writer.WriteUInt32(1); // active count
+ writer.WriteUInt32(DefaultCharacterId);
+ writer.WriteString16L(DefaultCharacterName);
+ writer.WriteUInt32(0); // secondsGreyedOut
+ writer.WriteUInt32(0); // deleted count
+ writer.WriteUInt32(11); // slot count
+ writer.WriteString16L(DefaultAccountName);
+ writer.WriteUInt32(1); // useTurbineChat
+ writer.WriteUInt32(1); // hasThroneOfDestiny
+ return writer.ToArray();
+ }
+
+ private static byte[] BuildOpcodeOnlyBody(uint opcode)
+ {
+ byte[] body = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, opcode);
+ return body;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs
new file mode 100644
index 00000000..0c817f0c
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs
@@ -0,0 +1,179 @@
+using System.Buffers.Binary;
+using System.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests.Transport;
+
+///
+/// Tests of the N0 harness plumbing: the deterministic
+/// fault injector and the that binds a REAL
+/// to the with no
+/// sockets anywhere.
+///
+public sealed class FakeAceTransportTests
+{
+ // ---- LossyLink ----
+
+ [Fact]
+ public void LossyLink_DropNextAndDropAt_DropDeterministically()
+ {
+ var link = new LossyLink();
+ link.DropNext(LinkDirection.ClientToServer);
+ link.DropAt(LinkDirection.ClientToServer, 2);
+
+ Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // index 0: DropNext
+ Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 })); // index 1
+ Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 })); // index 2: DropAt
+ Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 4 })); // index 3
+
+ Assert.Equal(4, link.TransmitCount(LinkDirection.ClientToServer));
+ Assert.Equal(2, link.DroppedCount(LinkDirection.ClientToServer));
+ Assert.Equal(2, link.DeliveredCount(LinkDirection.ClientToServer));
+ // Directions are independent.
+ Assert.Equal(0, link.TransmitCount(LinkDirection.ServerToClient));
+ }
+
+ [Fact]
+ public void LossyLink_PredicateDrop_IsPersistent()
+ {
+ var link = new LossyLink();
+ link.Drop(LinkDirection.ServerToClient, (_, datagram) => datagram[0] == 0xAA);
+
+ Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA }));
+ Assert.Single(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xBB }));
+ Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA }));
+ Assert.Equal(2, link.DroppedCount(LinkDirection.ServerToClient));
+ }
+
+ [Fact]
+ public void LossyLink_Reorder_SwapsAdjacentDatagrams()
+ {
+ var link = new LossyLink();
+ link.Reorder(LinkDirection.ClientToServer);
+
+ Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // held
+ IReadOnlyList delivered =
+ link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 });
+ Assert.Equal(2, delivered.Count);
+ Assert.Equal(2, delivered[0][0]); // the follower first
+ Assert.Equal(1, delivered[1][0]); // then the held one
+
+ // A held datagram with no follower can be force-released.
+ link.Reorder(LinkDirection.ClientToServer);
+ Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 }));
+ IReadOnlyList drained = link.DrainHeld(LinkDirection.ClientToServer);
+ Assert.Equal(3, Assert.Single(drained)[0]);
+ }
+
+ [Fact]
+ public void LossyLink_SeededRandomLoss_IsDeterministic()
+ {
+ var first = new LossyLink();
+ var second = new LossyLink();
+ first.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42);
+ second.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42);
+
+ for (int i = 0; i < 100; i++)
+ {
+ byte[] datagram = { (byte)i };
+ Assert.Equal(
+ first.Transmit(LinkDirection.ClientToServer, datagram).Count,
+ second.Transmit(LinkDirection.ClientToServer, datagram).Count);
+ }
+
+ // At 50% over 100 datagrams both outcomes occur.
+ Assert.True(first.DroppedCount(LinkDirection.ClientToServer) > 0);
+ Assert.True(first.DeliveredCount(LinkDirection.ClientToServer) > 0);
+ }
+
+ // ---- FakeAceTransport end-to-end ----
+
+ ///
+ /// The N0 goal made concrete: a genuine Connect() /
+ /// EnterWorld() / Tick() / Dispose() lifecycle runs
+ /// against the ACE-behaviour model with zero sockets — including both
+ /// ISAAC streams staying aligned end-to-end and retail's graceful-logout
+ /// order at teardown.
+ ///
+ [Fact]
+ public void RealWorldSession_HandshakeEnterWorldTickAndGracefulLogout_NoSockets()
+ {
+ var transport = new FakeAceTransport();
+ var session = new WorldSession(
+ new IPEndPoint(IPAddress.Loopback, 9000),
+ transport);
+ try
+ {
+ session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
+ Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState);
+ Assert.NotNull(session.Characters);
+ CharacterList.Character character = Assert.Single(session.Characters!.Characters);
+ Assert.Equal(FakeAceTransport.DefaultCharacterName, character.Name);
+ Assert.Equal(FakeAceTransport.DefaultAccountName, session.Characters.AccountName);
+
+ var messages = new List();
+ session.ServerMessageReceived += m => messages.Add(m.Message);
+
+ session.EnterWorld(0, TimeSpan.FromSeconds(10));
+ Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
+
+ // A world message flows model → link → async receive loop →
+ // Tick() → typed event.
+ transport.Model.EnqueueGameMessage(
+ BuildServerMessage("hello acdream"),
+ GameMessageGroup.UIQueue);
+ transport.PumpServer();
+ DateTime deadline = DateTime.UtcNow.AddSeconds(10);
+ while (messages.Count == 0 && DateTime.UtcNow < deadline)
+ {
+ session.Tick();
+ Thread.Sleep(5);
+ }
+
+ Assert.Equal("hello acdream", Assert.Single(messages));
+
+ // The model saw the genuine ordered client stream, and neither
+ // direction desynced its ISAAC keystream.
+ Assert.Equal(
+ new[]
+ {
+ CharacterEnterWorld.EnterWorldRequestOpcode,
+ CharacterEnterWorld.EnterWorldOpcode,
+ },
+ transport.Model.DispatchedMessages.Select(ReadOpcode).ToArray());
+ Assert.Equal(0, transport.Model.CrcDropCount);
+ Assert.Equal(0, transport.Model.DuplicateDropCount);
+ Assert.Equal(256, transport.Model.Crypto.Headroom);
+ }
+ finally
+ {
+ session.Dispose();
+ }
+
+ // Dispose ran retail's graceful order — 0xF653 request, the model's
+ // scripted confirmation, then the transport Disconnect that
+ // terminates the model exactly like ACE's session teardown.
+ Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
+ Assert.True(transport.Model.IsTerminated);
+ Assert.Equal(
+ AceTerminationReason.PacketHeaderDisconnect,
+ transport.Model.TerminationReason);
+ Assert.Equal(
+ CharacterLogOff.Opcode,
+ ReadOpcode(transport.Model.DispatchedMessages[^1]));
+ Assert.Equal(0, transport.Model.CrcDropCount);
+ }
+
+ private static uint ReadOpcode(byte[] messageBody) =>
+ BinaryPrimitives.ReadUInt32LittleEndian(messageBody);
+
+ private static byte[] BuildServerMessage(string text)
+ {
+ var writer = new PacketWriter(64);
+ writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0
+ writer.WriteString16L(text);
+ writer.WriteUInt32(1); // ChatMessageType
+ return writer.ToArray();
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Transport/LossyLink.cs b/tests/AcDream.Core.Net.Tests/Transport/LossyLink.cs
new file mode 100644
index 00000000..dc04a838
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/LossyLink.cs
@@ -0,0 +1,168 @@
+namespace AcDream.Core.Net.Tests.Transport;
+
+internal enum LinkDirection
+{
+ ClientToServer,
+ ServerToClient,
+}
+
+///
+/// Deterministic datagram fault injector between two endpoints. Pure data
+/// structure — no sockets, no threads, no wall clock. Callers push each
+/// datagram through and deliver whatever comes back,
+/// in order.
+///
+///
+/// Fault evaluation order per datagram (first match wins):
+/// scheduled per-index drops () → one-shot drop budget
+/// () → persistent predicates () →
+/// seeded random loss (). A surviving datagram is
+/// then subject to an armed : the next survivor is held
+/// back and delivered immediately AFTER the survivor that follows it
+/// (adjacent swap). Datagram bytes are copied on entry, so callers may pass
+/// stack-allocated spans.
+///
+///
+internal sealed class LossyLink
+{
+ private sealed class DirectionState
+ {
+ public int TransmitIndex;
+ public int PendingDropCount;
+ public readonly HashSet DropIndices = new();
+ public readonly List> DropPredicates = new();
+ public Random? LossRandom;
+ public double LossProbability;
+ public bool ReorderArmed;
+ public byte[]? Held;
+ public int Dropped;
+ public int Delivered;
+ }
+
+ private readonly DirectionState _clientToServer = new();
+ private readonly DirectionState _serverToClient = new();
+
+ private DirectionState State(LinkDirection direction) =>
+ direction == LinkDirection.ClientToServer ? _clientToServer : _serverToClient;
+
+ /// Drop the next datagrams in .
+ public void DropNext(LinkDirection direction, int count = 1)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(count);
+ State(direction).PendingDropCount += count;
+ }
+
+ /// Drop the datagram with the given per-direction transmit index (0-based).
+ public void DropAt(LinkDirection direction, int transmitIndex) =>
+ State(direction).DropIndices.Add(transmitIndex);
+
+ ///
+ /// Drop every datagram matching (persistent;
+ /// receives the per-direction transmit index and the datagram bytes).
+ ///
+ public void Drop(LinkDirection direction, Func predicate) =>
+ State(direction).DropPredicates.Add(predicate);
+
+ ///
+ /// Swap the next two surviving datagrams: the next survivor is held and
+ /// released right after the survivor that follows it.
+ ///
+ public void Reorder(LinkDirection direction) =>
+ State(direction).ReorderArmed = true;
+
+ ///
+ /// Enable seeded random loss: each surviving datagram is dropped with
+ /// using Random(seed) — fully
+ /// deterministic for a given seed + transmit sequence.
+ ///
+ public void RandomLoss(LinkDirection direction, double probability, int seed)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(probability);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(probability, 1.0);
+ DirectionState state = State(direction);
+ state.LossProbability = probability;
+ state.LossRandom = new Random(seed);
+ }
+
+ public int TransmitCount(LinkDirection direction) => State(direction).TransmitIndex;
+ public int DroppedCount(LinkDirection direction) => State(direction).Dropped;
+ public int DeliveredCount(LinkDirection direction) => State(direction).Delivered;
+
+ ///
+ /// Push one datagram through the link. Returns the datagrams to deliver
+ /// now, in order (0, 1, or 2 entries — 2 when a held reordered datagram
+ /// is released).
+ ///
+ public IReadOnlyList Transmit(LinkDirection direction, ReadOnlySpan datagram)
+ {
+ DirectionState state = State(direction);
+ int index = state.TransmitIndex++;
+ byte[] copy = datagram.ToArray();
+
+ bool drop = state.DropIndices.Remove(index);
+ if (!drop && state.PendingDropCount > 0)
+ {
+ state.PendingDropCount--;
+ drop = true;
+ }
+
+ if (!drop)
+ {
+ foreach (Func predicate in state.DropPredicates)
+ {
+ if (predicate(index, copy))
+ {
+ drop = true;
+ break;
+ }
+ }
+ }
+
+ if (!drop
+ && state.LossRandom is not null
+ && state.LossRandom.NextDouble() < state.LossProbability)
+ {
+ drop = true;
+ }
+
+ if (drop)
+ {
+ state.Dropped++;
+ return Array.Empty();
+ }
+
+ if (state.ReorderArmed)
+ {
+ state.ReorderArmed = false;
+ state.Held = copy;
+ return Array.Empty();
+ }
+
+ if (state.Held is not null)
+ {
+ byte[] held = state.Held;
+ state.Held = null;
+ state.Delivered += 2;
+ return new[] { copy, held };
+ }
+
+ state.Delivered++;
+ return new[] { copy };
+ }
+
+ ///
+ /// Force-release a datagram held by that nothing
+ /// followed (it would otherwise be stuck forever). Returns the held
+ /// datagram or an empty list.
+ ///
+ public IReadOnlyList DrainHeld(LinkDirection direction)
+ {
+ DirectionState state = State(direction);
+ if (state.Held is null)
+ return Array.Empty();
+ byte[] held = state.Held;
+ state.Held = null;
+ state.Delivered++;
+ return new[] { held };
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Transport/VirtualClock.cs b/tests/AcDream.Core.Net.Tests/Transport/VirtualClock.cs
new file mode 100644
index 00000000..5bf05191
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Transport/VirtualClock.cs
@@ -0,0 +1,54 @@
+namespace AcDream.Core.Net.Tests.Transport;
+
+///
+/// Deterministic monotonic time source for transport tests. Exposes the same
+/// (timestamp, frequency) shape as System.Diagnostics.Stopwatch
+/// (GetTimestamp() + Frequency) so slice N1 can inject it
+/// behind the production TransportClock without adapting call sites.
+/// Time only moves when a test calls .
+///
+///
+/// Deliberately dependency-free (System only) and NOT tied to the machine's
+/// Stopwatch.Frequency: a fixed 100 ns tick makes every gate
+/// computation reproducible across platforms.
+///
+///
+internal sealed class VirtualClock
+{
+ ///
+ /// Fixed tick rate: 100 ns ticks (10,000,000 per second), equal to
+ /// so
+ /// arithmetic maps 1:1 onto clock ticks.
+ ///
+ public const long TicksPerSecond = TimeSpan.TicksPerSecond;
+
+ private long _timestamp;
+
+ public VirtualClock(long startTimestamp = 0) => _timestamp = startTimestamp;
+
+ /// Stopwatch.Frequency equivalent.
+ public long Frequency => TicksPerSecond;
+
+ /// Stopwatch.GetTimestamp() equivalent.
+ public long GetTimestamp() => _timestamp;
+
+ ///
+ /// Seconds since the clock's epoch as a double — the shape of the
+ /// retail/ACE PortalYearTicks-style wall values written into TimeSync
+ /// payloads and the packet header's 16-bit Time field.
+ ///
+ public double Seconds => (double)_timestamp / TicksPerSecond;
+
+ /// Move time forward. The clock is monotonic — negative deltas throw.
+ public void Advance(TimeSpan delta)
+ {
+ if (delta < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(delta),
+ "the clock is monotonic — it cannot go backwards");
+ }
+
+ _timestamp += delta.Ticks;
+ }
+}