From 7e9134b4d1d7338f62f0d45293a916c666a50832 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 11:07:13 +0200 Subject: [PATCH] test(net): N0 - ACE-behaviour double, virtual clock, lossy link Campaign N slice N0 (docs/plans/2026-07-29-network-transport-campaign.md): the referee that slices N1-N5 are graded against, test-project only, zero production changes. - VirtualClock: Stopwatch-shaped deterministic time source (fixed 100 ns ticks) that N1 will inject behind the production TransportClock. - AceCryptoModel: verbatim port of ACE CryptoSystem Search/ConsumeKey over our IsaacRandom - 256-key window, parked-key set, Headroom/OrphanCount diagnostics (CryptoSystem.cs:8-49 cited per method). - AceSessionModel: transport-free ACE NetworkSession over raw datagrams, every rule cited to NetworkSession.cs - CRC-before-everything silent drop, cleartext-NAK early return (no timeout refresh, :283-308), 60 s timeout refresh (:329-331), exact-equality ack dedup exemption (:342-347), desired+2 NAK trigger with 1 s limit (:351-363), >window AbnormalSequenceReceived (:393-397), the :474-476 watermark hole, ack-value cache prune (:663-673), fragment gate (:532-543), seq>=2 caching (:730), Retransmission-flag resends with the ORIGINAL IssacXor (:675-686), RejectRetransmit, 2 s cleartext cumulative ack, 20 s TimeSync, EchoResponse, 120 s cache prune (:251-262). ACE's raw wrap-unsafe comparisons are modeled bug-for-bug, not fixed. - LossyLink: deterministic drop/reorder/seeded-loss fault injector, pure data structure. - FakeAceTransport: IWorldSessionTransport binding a REAL WorldSession to the model through the link, with the handshake scripted (ConnectRequest reusing the negotiation fixture layout, CharacterList, ServerReady, logoff confirmation) - genuine Connect/EnterWorld/Tick/Dispose with no sockets. - 19 new tests pin the double, including CleartextNonAckAdvancesWatermark_TheAceHole (the self-induced wedge behind scope rows TS-57/TS-58/AP-125), re-key = permanent orphan, unrequested-resend window burn, the 115-id NAK cap boundary, and a full no-socket session lifecycle with both ISAAC streams verified aligned end-to-end. Core.Net suite: 678 passed / 0 failed (659 existing + 19 new). Co-Authored-By: Claude Sonnet 5 --- .../Transport/AceCryptoModel.cs | 112 ++ .../Transport/AceSessionModel.cs | 959 ++++++++++++++++++ .../Transport/AceSessionModelTests.cs | 668 ++++++++++++ .../Transport/FakeAceTransport.cs | 219 ++++ .../Transport/FakeAceTransportTests.cs | 179 ++++ .../Transport/LossyLink.cs | 168 +++ .../Transport/VirtualClock.cs | 54 + 7 files changed, 2359 insertions(+) create mode 100644 tests/AcDream.Core.Net.Tests/Transport/AceCryptoModel.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/LossyLink.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/VirtualClock.cs 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; + } +}