using System.Buffers.Binary; using System.Net; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; using AcDream.Core.Net.Transport; namespace AcDream.Core.Net.Tests.Transport; /// /// Campaign N Slice N2 — inbound sequence-aligned ISAAC + the NAK set. /// Unit tests pin the tracker against a shadow ISAAC seeded identically /// (the shadow's draw order IS the sequence order the tracker must keep); /// conformance tests run a REAL against the N0 /// ACE-behaviour double and prove the second fatal bug is gone: one lost /// S2C packet no longer desyncs the inbound keystream. /// public sealed class InboundSequenceTrackerTests { private const uint Seed = 0x5EED5EEDu; // ===================================================================== // The decisive unit test — the bug that exists today: pre-N2, the // arrival-order burn desyncs the keystream at the first gap and every // later encrypted packet fails checksum forever. // ===================================================================== [Fact] public void GapWalk_ParksTheMissingKey_LaterPacketsAndLateArrivalDecode() { (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 9); IsaacRandom shadow = MakeIsaac(Seed); uint w10 = shadow.Next(); uint w11 = shadow.Next(); uint w12 = shadow.Next(); uint w13 = shadow.Next(); uint w14 = shadow.Next(); uint w15 = shadow.Next(); // In-order packets draw in sequence order. Assert.Equal(w10, Admitted(tracker, 10)); Assert.Equal(w11, Admitted(tracker, 11)); // Sequence 12 is lost. 13 arrives: the walk pre-draws 12's word and // parks it BEFORE 13's own key (landmine #4) — 13 decodes with w13, // not w12 (impossible pre-N2). Assert.Equal(w13, Admitted(tracker, 13)); Assert.Equal(1, tracker.NakCount); Assert.Equal(1, stats.KeysParked); Assert.Equal(13u, tracker.HighestIdReceived); // 14 keeps flowing on the aligned stream. Assert.Equal(w14, Admitted(tracker, 14)); // The late 12 decodes with the PARKED key at zero fresh cost. Assert.Equal(w12, Admitted(tracker, 12)); Assert.Equal(0, tracker.NakCount); Assert.Equal(14u, tracker.HighestIdReceived); // And the next fresh packet takes the next fresh word. Assert.Equal(w15, Admitted(tracker, 15)); Assert.Equal(0, stats.InboundDupsDropped); Assert.Equal(0, stats.InboundSanityDrops); } // ===================================================================== // Duplicates and re-parks — ProcessNewSeqNum @ 0x00544690 step 2 and // ProcessPacket @ 0x00544790 step 5 // ===================================================================== [Fact] public void Duplicate_NeverNakked_DropsAtZeroKeystreamCost() { (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 9); IsaacRandom shadow = MakeIsaac(Seed); uint w10 = shadow.Next(); uint w11 = shadow.Next(); Assert.Equal(w10, Admitted(tracker, 10)); // A duplicate of the already-decoded 10: dropped BEFORE any // keystream access (pre-N2 this burned one word and shifted the // stream). InboundSequenceTracker.Admission dup = tracker.Admit(10, encrypted: true); Assert.True(dup.Drop); Assert.Equal(1, stats.InboundDupsDropped); // The shadow position is unchanged: 11 draws the very next word. Assert.Equal(w11, Admitted(tracker, 11)); } [Fact] public void ChecksumFailureRepark_TheRetransmissionDecodesWithTheSameKey() { (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 9); IsaacRandom shadow = MakeIsaac(Seed); uint w10 = shadow.Next(); uint w11 = shadow.Next(); // 10 arrives corrupt: admission consumed w10, verification failed, // the session re-parks the consumed key (step 5) — carrying the // admission's draw order, so the AD-51 reclaim can still place the // word in the stream — and drops. InboundSequenceTracker.Admission corrupt = tracker.Admit(10, encrypted: true); Assert.False(corrupt.Drop); Assert.Equal(w10, corrupt.VerifyKey); tracker.ReparkKey(10, w10, corrupt.VerifyKeyDrawOrder); Assert.Equal(1, tracker.NakCount); Assert.Equal(1, stats.KeysParked); // The byte-identical retransmission decodes with the SAME word. Assert.Equal(w10, Admitted(tracker, 10)); Assert.Equal(0, tracker.NakCount); // Alignment held throughout. Assert.Equal(w11, Admitted(tracker, 11)); } // ===================================================================== // The cleartext walk rules — ProcessNewestSeqNum @ 0x00541930 // ===================================================================== [Fact] public void Cleartext_AtHighestPlusOne_NaksItsOwnBorrowedId() { // The least obvious line of the port: a cleartext packet walks to // seq + 1, so its OWN sequence gets NAKed — cleartext borrows an // already-delivered sequence, and the real encrypted packet at that // id may still be in flight (retail `if ((header_ & 2) == 0) // seqID_ += 1`). (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 9); IsaacRandom shadow = MakeIsaac(Seed); uint w10 = shadow.Next(); uint w11 = shadow.Next(); InboundSequenceTracker.Admission cleartext = tracker.Admit(10, encrypted: false); Assert.False(cleartext.Drop); Assert.Null(cleartext.VerifyKey); Assert.Equal(10u, tracker.HighestIdReceived); Assert.Equal(1, tracker.NakCount); Assert.Equal(1, stats.KeysParked); // The real encrypted 10 arrives later: parked key, exact word. Assert.Equal(w10, Admitted(tracker, 10)); Assert.Equal(0, tracker.NakCount); Assert.Equal(w11, Admitted(tracker, 11)); } [Fact] public void Cleartext_AtHighest_NoNak_NoKey_NoWatermarkChange() { (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 9); IsaacRandom shadow = MakeIsaac(Seed); uint w10 = shadow.Next(); uint w11 = shadow.Next(); Assert.Equal(w10, Admitted(tracker, 10)); // ACE's pure ack reuses the current sequence: not newer → no walk, // no key, no watermark movement, processed additively. InboundSequenceTracker.Admission ack = tracker.Admit(10, encrypted: false); Assert.False(ack.Drop); Assert.Null(ack.VerifyKey); Assert.Equal(10u, tracker.HighestIdReceived); Assert.Equal(0, tracker.NakCount); Assert.Equal(0, stats.KeysParked); Assert.Equal(w11, Admitted(tracker, 11)); } // ===================================================================== // Sanity window — SeqIDSanityCheck @ 0x00543A20 // ===================================================================== [Fact] public void SanityWindow_HighestPlus0x7FFF_Accepted_OnePastIt_Dropped() { // The accept boundary (walks the whole window — retail does too). (InboundSequenceTracker accepted, _) = CreateTracker(initialWatermark: 100); InboundSequenceTracker.Admission atBoundary = accepted.Admit(100u + 0x7FFFu, encrypted: true); Assert.False(atBoundary.Drop); Assert.Equal(100u + 0x7FFFu, accepted.HighestIdReceived); // One past it: dropped at zero keystream cost. (InboundSequenceTracker dropped, TransportStats stats) = CreateTracker(initialWatermark: 100); IsaacRandom shadow = MakeIsaac(Seed); uint w101 = shadow.Next(); InboundSequenceTracker.Admission pastBoundary = dropped.Admit(100u + 0x8000u, encrypted: true); Assert.True(pastBoundary.Drop); Assert.Equal(1, stats.InboundSanityDrops); Assert.Equal(100u, dropped.HighestIdReceived); Assert.Equal(0, dropped.NakCount); Assert.Equal(w101, Admitted(dropped, 101)); } [Fact] public void SanityWindow_IsWrapSafe() { // Watermark near the 32-bit wrap: the horizon lands past 0. (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 0xFFFFFF00u); // watermark + 0x8000 wraps to 0x7F00 — still one past the horizon, // still dropped. InboundSequenceTracker.Admission pastBoundary = tracker.Admit(unchecked(0xFFFFFF00u + 0x8000u), encrypted: true); Assert.True(pastBoundary.Drop); Assert.Equal(1, stats.InboundSanityDrops); // An in-window post-wrap sequence is fine (small hop to keep the // walk short): 0xFFFFFF00 → 3 is newer across the wrap and inside // the horizon. InboundSequenceTracker.Admission postWrap = tracker.Admit(3u, encrypted: true); Assert.False(postWrap.Drop); Assert.Equal(3u, tracker.HighestIdReceived); } [Fact] public void GapWalk_SkipsSequenceZero_AcrossTheWrap() { // Retail's walk skips id 0 (`if (esi_1 != 0)`): sequence 0 is the // handshake id and never carries a keystream word. (InboundSequenceTracker tracker, _) = CreateTracker(initialWatermark: 0xFFFFFFFEu); IsaacRandom shadow = MakeIsaac(Seed); uint wMax = shadow.Next(); // parked for 0xFFFFFFFF uint w1 = shadow.Next(); // parked for 1 (0 skipped between) uint w2 = shadow.Next(); // the arriving packet's own key Assert.Equal(w2, Admitted(tracker, 2)); Assert.Equal(2, tracker.NakCount); // Ascending raw-uint order, like retail's AVL enumeration. var naks = new List(); tracker.CopyNakkedSequencesAscending(naks); Assert.Equal(new uint[] { 1u, 0xFFFFFFFFu }, naks); // Both parked keys decode their late arrivals. Assert.Equal(wMax, Admitted(tracker, 0xFFFFFFFFu)); Assert.Equal(w1, Admitted(tracker, 1)); Assert.Equal(0, tracker.NakCount); } // ===================================================================== // RejectRetransmit — HandleEmptyAck @ 0x005448F0 // ===================================================================== [Fact] public void RejectRetransmit_RemovesIds_AndTheStreamStaysAligned() { (InboundSequenceTracker tracker, TransportStats stats) = CreateTracker(initialWatermark: 9); IsaacRandom shadow = MakeIsaac(Seed); uint w10 = shadow.Next(); _ = shadow.Next(); // w11 — parked, then abandoned _ = shadow.Next(); // w12 — parked, then abandoned uint w13 = shadow.Next(); uint w14 = shadow.Next(); Assert.Equal(w10, Admitted(tracker, 10)); Assert.Equal(w13, Admitted(tracker, 13)); // parks 11 + 12 Assert.Equal(2, tracker.NakCount); // The server answers RejectRetransmit [11, 12]: silent abandonment. Span ids = stackalloc byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(ids, 11u); BinaryPrimitives.WriteUInt32LittleEndian(ids.Slice(4), 12u); tracker.OnRejectRetransmit(ids, count: 2); Assert.Equal(0, tracker.NakCount); // Alignment holds: the words were already drawn in sequence order. Assert.Equal(w14, Admitted(tracker, 14)); // A very late 11 after abandonment is a plain duplicate now — // dropped at zero cost. Assert.True(tracker.Admit(11, encrypted: true).Drop); Assert.Equal(1, stats.InboundDupsDropped); } // ===================================================================== // Zero-alloc steady state // ===================================================================== [Fact] public void WarmAdmit_NoGap_AllocatesNothing() { (InboundSequenceTracker tracker, _) = CreateTracker(initialWatermark: 1); uint sequence = 2; for (int i = 0; i < 128; i++) _ = tracker.Admit(sequence++, encrypted: true); long before = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 1_000; i++) _ = tracker.Admit(sequence++, encrypted: true); long allocated = GC.GetAllocatedBytesForCurrentThread() - before; Assert.Equal(0, allocated); } // ===================================================================== // Conformance against the N0 ACE-behaviour double (real WorldSession) // ===================================================================== /// /// The watermark-init pin: ACE's re-prime dance (cleartext ConnectRequest /// at sequence 0, first encrypted flush re-primes to 1, first encrypted /// sequenced packet at 2 — NetworkSession.cs:716-717) must produce ZERO /// NAKs and zero spurious drops across a clean lifecycle with the /// tracker's init watermark of 1. /// [Fact] public void CleanLifecycle_ZeroNaks_ZeroSpuriousDrops() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); try { session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); AssertNoInboundFaults(session); session.EnterWorld(0, TimeSpan.FromSeconds(10)); AssertNoInboundFaults(session); // One in-world round trip. var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); transport.Model.EnqueueGameMessage( BuildServerMessage("clean lifecycle"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => messages.Count > 0); Assert.Equal("clean lifecycle", Assert.Single(messages)); AssertNoInboundFaults(session); // The model's dance, pinned: the first ENCRYPTED sequenced S2C // packet is sequence 2 (never 1) — the fact the init watermark // adaptation is built on. uint minEncrypted = uint.MaxValue; uint maxSequence = 0; foreach (byte[] datagram in transport.Model.SentDatagrams) { PacketHeader header = PacketHeader.Unpack(datagram); if (header.HasFlag(PacketHeaderFlags.EncryptedChecksum) && header.Sequence != 0 && header.Sequence < minEncrypted) { minEncrypted = header.Sequence; } maxSequence = SequenceMath.Max(maxSequence, header.Sequence); } Assert.Equal(2u, minEncrypted); // The tracker followed the whole stream: watermark == the // newest sequence ACE ever emitted, with an empty NAK set. Assert.Equal( maxSequence, session.Transport!.Inbound.HighestIdReceived); } finally { session.Dispose(); } Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); Assert.Equal(0, transport.Model.CrcDropCount); } /// /// The N2 win end-to-end: one lost S2C packet mid-stream (a Count=2 /// fragment set spanning two packets — the model splits >448 B /// messages) no longer kills the inbound cipher. Later packets STILL /// DECODE, the missing id carries a parked key, and ACE's cached-shape /// late redelivery completes the fragment set intact. /// [Fact] public void S2CLoss_LaterPacketsStillDecode_LateRedeliveryCompletesTheMessage() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); try { session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); session.EnterWorld(0, TimeSpan.FromSeconds(10)); var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); // A >448 B message splits into a Count=2 fragment set spanning // two S2C packets; the link eats the FIRST of them. string bigText = new string('x', 600); transport.Link.DropNext(LinkDirection.ServerToClient); transport.Model.EnqueueGameMessage( BuildServerMessage(bigText), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil( session, () => session.Transport!.Stats.KeysParked > 0); // The missing id is parked, the message can't complete yet. Assert.Equal(1, session.Transport!.Inbound.NakCount); Assert.Equal(1, session.Transport.Stats.KeysParked); Assert.Empty(messages); // THE WIN: traffic AFTER the loss still decodes (pre-N2 every // later encrypted packet failed checksum forever). transport.Model.EnqueueGameMessage( BuildServerMessage("after the loss"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => messages.Count > 0); Assert.Equal("after the loss", Assert.Single(messages)); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); // Late byte-identical redelivery of the dropped datagram (the // bytes ACE cached and would resend): decodes with the parked // key, completes the fragment set, dispatches intact. var parked = new List(); session.Transport.Inbound.CopyNakkedSequencesAscending(parked); uint missingSequence = Assert.Single(parked); byte[]? droppedDatagram = null; foreach (byte[] datagram in transport.Model.SentDatagrams) { if (PacketHeader.Unpack(datagram).Sequence == missingSequence) { droppedDatagram = datagram; break; } } Assert.NotNull(droppedDatagram); transport.InjectServerDatagram(droppedDatagram!); PumpUntil(session, () => messages.Count > 1); Assert.Equal(2, messages.Count); Assert.Equal(bigText, messages[1]); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(0, session.Transport.Stats.InboundDupsDropped); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); } finally { session.Dispose(); } } /// /// The double-dispatch bug closes: a duplicate delivery of an /// already-processed server packet drops BEFORE dispatch, at zero /// keystream cost. /// [Fact] public void DuplicateServerPacket_DropsBeforeDispatch() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); try { session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); session.EnterWorld(0, TimeSpan.FromSeconds(10)); var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); transport.Model.EnqueueGameMessage( BuildServerMessage("once only"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => messages.Count > 0); Assert.Equal("once only", Assert.Single(messages)); // Redeliver the exact datagram that carried it. byte[]? carrier = null; foreach (byte[] datagram in transport.Model.SentDatagrams) { PacketHeader header = PacketHeader.Unpack(datagram); if (header.HasFlag(PacketHeaderFlags.BlobFragments) && header.Sequence == session.Transport!.Inbound.HighestIdReceived) { carrier = datagram; } } Assert.NotNull(carrier); transport.InjectServerDatagram(carrier!); PumpUntil( session, () => session.Transport!.Stats.InboundDupsDropped > 0); // Dropped before dispatch: the message did NOT arrive twice. Assert.Single(messages); Assert.Equal(1, session.Transport!.Stats.InboundDupsDropped); // And the keystream did not move: fresh traffic still decodes. transport.Model.EnqueueGameMessage( BuildServerMessage("still aligned"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => messages.Count > 1); Assert.Equal("still aligned", messages[1]); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); } finally { session.Dispose(); } } /// /// Sequence-0 packets bypass the tracker entirely: cleartext seq-0 is /// handshake/control (processed, no watermark/NAK movement); encrypted /// seq-0 drops before any keystream access. /// [Fact] public void SequenceZero_CleartextBypassesTracker_EncryptedDrops() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); try { session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); session.EnterWorld(0, TimeSpan.FromSeconds(10)); uint watermarkBefore = session.Transport!.Inbound.HighestIdReceived; long parkedBefore = session.Transport.Stats.KeysParked; var serverTimes = new List(); session.ServerTimeUpdated += t => serverTimes.Add(t); // A crafted cleartext seq-0 TimeSync control packet processes // through the handshake/control path without touching the // tracker. byte[] timeSyncBody = new byte[8]; BinaryPrimitives.WriteInt64LittleEndian( timeSyncBody, BitConverter.DoubleToInt64Bits(777.5)); transport.InjectServerDatagram(PacketCodec.Encode( new PacketHeader { Sequence = 0, Flags = PacketHeaderFlags.TimeSync, }, timeSyncBody, outboundIsaac: null)); PumpUntil(session, () => serverTimes.Contains(777.5)); Assert.Equal( watermarkBefore, session.Transport.Inbound.HighestIdReceived); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(parkedBefore, session.Transport.Stats.KeysParked); // An encrypted seq-0 packet does not exist on retail's wire: // dropped before any keystream access — later traffic proves // the wheel never moved. var throwawayIsaac = MakeIsaac(0xDEADBEEFu); transport.InjectServerDatagram(PacketCodec.Encode( new PacketHeader { Sequence = 0, Flags = PacketHeaderFlags.TimeSync | PacketHeaderFlags.EncryptedChecksum, }, timeSyncBody, throwawayIsaac)); var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); transport.Model.EnqueueGameMessage( BuildServerMessage("wheel intact"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => messages.Count > 0); Assert.Equal("wheel intact", Assert.Single(messages)); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); } finally { session.Dispose(); } } // ===================================================================== // Fixture helpers // ===================================================================== private static (InboundSequenceTracker Tracker, TransportStats Stats) CreateTracker(uint initialWatermark) { var stats = new TransportStats(); return ( new InboundSequenceTracker(MakeIsaac(Seed), stats, initialWatermark), stats); } /// Admit an encrypted packet that must NOT drop; returns the /// verify key the tracker handed out. private static uint Admitted(InboundSequenceTracker tracker, uint sequence) { InboundSequenceTracker.Admission admission = tracker.Admit(sequence, encrypted: true); Assert.False(admission.Drop); Assert.NotNull(admission.VerifyKey); return admission.VerifyKey!.Value; } private static void AssertNoInboundFaults(WorldSession session) { AcDream.Core.Net.Transport.ReliableTransport? transport = session.Transport; Assert.NotNull(transport); Assert.Equal(0, transport!.Inbound.NakCount); Assert.Equal(0, transport.Stats.KeysParked); Assert.Equal(0, transport.Stats.InboundDupsDropped); Assert.Equal(0, transport.Stats.InboundSanityDrops); Assert.Equal(0, transport.Stats.ChecksumFailures); } private static void PumpUntil(WorldSession session, Func condition) { DateTime deadline = DateTime.UtcNow.AddSeconds(10); while (!condition() && DateTime.UtcNow < deadline) { session.Tick(); Thread.Sleep(5); } Assert.True(condition(), "condition not reached before the deadline"); } private static byte[] BuildServerMessage(string text) { var writer = new PacketWriter(64 + text.Length); writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 writer.WriteString16L(text); writer.WriteUInt32(1); // ChatMessageType return writer.ToArray(); } private static IsaacRandom MakeIsaac(uint seed) { Span seedBytes = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); return new IsaacRandom(seedBytes); } }