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 N4 — client NAK emission + RejectRetransmit /// consumption: the 0.6 s STRICT gate on the shared timestamp /// (SharedNet::EnqueueNaks @ 0x00543BD0, the 0x41-mask test at /// 0x00543C03), the ≤114-id ascending cleartext RequestRetransmit /// (ReceiverData::GetNaks @ 0x005490C0, cap 0x72), and the AD-51 /// reclaimed-word pool that closes the ACE cleartext-reject keystream /// hazard (ACE's RejectRetransmit consumes a fresh cleartext /// sequence WITHOUT drawing a keystream word — NetworkSession.cs:299-304, /// :722-725, :743-748 — which retail's gap-walk invariant never /// anticipates). /// public sealed class NakEmissionTests { private const uint ClientSeed = 0x77EE88FFu; private const uint ServerSeed = 0x55DD66CCu; private const uint ClientId = 0x1234u; private const ushort SessionIteration = 0x0007; private const uint TrackerSeed = 0x5EED5EEDu; // ===================================================================== // The 0.6 s gate is STRICT — SharedNet::EnqueueNaks @ 0x00543BD0 // ===================================================================== [Fact] public void NakGate_ClosedAtExactly600ms_OpensJustPastIt() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); Admit(transport, 2u); Admit(transport, 4u); // parks id 3 Assert.Equal(1, transport.Inbound.NakCount); // Exactly 0.6 s since the gate armed at construction: STILL CLOSED // (the decomp's 0x41 mask bails on less-than OR equal — the branch // proceeds only on strictly greater; contrast the ack's >=). clock.Advance(TimeSpan.FromSeconds(0.6)); transport.Sweep(); Assert.Empty(sent); Assert.Equal(0, transport.Stats.NaksSent); // One millisecond past: open. clock.Advance(TimeSpan.FromMilliseconds(1)); transport.Sweep(); Assert.Single(sent); Assert.Equal(1, transport.Stats.NaksSent); // The stamp reset the gate: exactly 0.6 s later is closed again, // just past opens again. transport.Sweep(); clock.Advance(TimeSpan.FromSeconds(0.6)); transport.Sweep(); Assert.Single(sent); clock.Advance(TimeSpan.FromMilliseconds(1)); transport.Sweep(); Assert.Equal(2, sent.Count); Assert.Equal(2, transport.Stats.NaksSent); } // ===================================================================== // ONE shared timestamp (landmine #7) — ReceiverData::timeStamp_ @ +0x10 // ===================================================================== [Fact] public void SharedTimestamp_AckDelaysNak_NakDelaysAck() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); Admit(transport, 2u); // An ack goes out at t = 2.0 and stamps the SHARED timestamp. clock.Advance(TimeSpan.FromSeconds(2.0)); transport.Sweep(); Assert.Equal(1, transport.Stats.AcksSent); sent.Clear(); // A gap parks. The ack's stamp gates the NAK: exactly 0.6 s after // the ACK is still closed; just past opens. Admit(transport, 4u); // parks id 3 clock.Advance(TimeSpan.FromSeconds(0.6)); transport.Sweep(); Assert.Empty(sent); clock.Advance(TimeSpan.FromMilliseconds(1)); transport.Sweep(); byte[] nak = Assert.Single(sent); Assert.Equal( (uint)PacketHeaderFlags.RequestRetransmit, (uint)PacketHeader.Unpack(nak).Flags); sent.Clear(); // The NAK's stamp gates the ack the same way: the gap heals, and // the next cumulative ack waits the full 2.0 s from the NAK. Admit(transport, 3u); Assert.Equal(0, transport.Inbound.NakCount); clock.Advance(TimeSpan.FromSeconds(1.999)); transport.Sweep(); Assert.Empty(sent); Assert.Equal(1, transport.Stats.AcksSent); clock.Advance(TimeSpan.FromSeconds(0.001)); transport.Sweep(); Assert.Single(sent); Assert.Equal(2, transport.Stats.AcksSent); } [Fact] public void NakSweep_BothGatesOpen_EmitsTheNakAndNeverTheAck() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); Admit(transport, 2u); Admit(transport, 4u); // parks id 3 // Both gates are long since open; the NAK branch owns the sweep // exclusively (ClientNet::ProcessConnection @ 0x00545450 — // EnqueueNaks XOR EnqueuePak, never both). clock.Advance(TimeSpan.FromSeconds(5.0)); transport.Sweep(); byte[] nak = Assert.Single(sent); Assert.Equal( (uint)PacketHeaderFlags.RequestRetransmit, (uint)PacketHeader.Unpack(nak).Flags); Assert.Equal(1, transport.Stats.NaksSent); Assert.Equal(0, transport.Stats.AcksSent); } // ===================================================================== // Emission shape — SharedNet::EnqueueNaks @ 0x00543BD0 (mask 0x1000, // m_cbData = 4·count + 4 at 0x00543C3E) + landmine #6 // ===================================================================== [Fact] public void NakShape_CleartextExactFlags_BorrowedSequence_AscendingIds() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); // Two reliable sends so the borrowed sequence is nontrivial. transport.Outbound.SendGameMessage( MakeMessage(1), GameMessageGroup.UIQueue); transport.Outbound.SendGameMessage( MakeMessage(2), GameMessageGroup.UIQueue); Assert.Equal(3u, transport.Outbound.HighestIdSent); sent.Clear(); Admit(transport, 2u); Admit(transport, 6u); // parks 3, 4, 5 clock.Advance(TimeSpan.FromSeconds(0.7)); transport.Sweep(); byte[] nak = Assert.Single(sent); PacketHeader header = PacketHeader.Unpack(nak); // Flags: a raw equality — EXACTLY RequestRetransmit, no // EncryptedChecksum (landmine #6: ACE honours only the cleartext // form, NetworkSession.cs:283-284). Assert.Equal( (uint)PacketHeaderFlags.RequestRetransmit, (uint)header.Flags); // Borrowed sequence (no increment), session id, and the N4 // control-header rule: Time = interval id at emission, Iteration = // the session iteration (retail's shared header build at // 0x00547A84). Assert.Equal(transport.Outbound.HighestIdSent, header.Sequence); Assert.Equal(3u, transport.Outbound.HighestIdSent); Assert.Equal((ushort)ClientId, header.Id); Assert.Equal(transport.Clock.IntervalId, header.Time); Assert.Equal((ushort)2, header.Time); // 0.7 s of 0.5 s intervals + 1 Assert.Equal(SessionIteration, header.Iteration); // Body: u32 count + count × u32 ascending, little-endian. Assert.Equal((ushort)16, header.DataSize); Assert.Equal(PacketHeader.Size + 16, nak.Length); Assert.Equal( 3u, BinaryPrimitives.ReadUInt32LittleEndian( nak.AsSpan(PacketHeader.Size))); Assert.Equal( 3u, BinaryPrimitives.ReadUInt32LittleEndian( nak.AsSpan(PacketHeader.Size + 4))); Assert.Equal( 4u, BinaryPrimitives.ReadUInt32LittleEndian( nak.AsSpan(PacketHeader.Size + 8))); Assert.Equal( 5u, BinaryPrimitives.ReadUInt32LittleEndian( nak.AsSpan(PacketHeader.Size + 12))); // Cleartext: decodes with a null keystream, and our own parser // reads the id list back. PacketCodec.PacketDecodeResult decoded = PacketCodec.TryDecode(nak, inboundIsaac: null); Assert.True(decoded.IsOk, decoded.Error.ToString()); Assert.Equal( new uint[] { 3u, 4u, 5u }, decoded.Packet!.Optional.RetransmitRequests); // The emission COPIES the set — parked entries (and their keys) // stay until the retransmission decodes or the server abandons // them. Assert.Equal(3, transport.Inbound.NakCount); } [Fact] public void NakList_CapsAt114LowestAscending_SetUntouched() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); Admit(transport, 2u); Admit(transport, 203u); // parks 3..202 — 200 ids Assert.Equal(200, transport.Inbound.NakCount); clock.Advance(TimeSpan.FromSeconds(0.7)); transport.Sweep(); byte[] nak = Assert.Single(sent); PacketHeader header = PacketHeader.Unpack(nak); // 114 ids (ReceiverData::GetNaks @ 0x005490C0 caps 0x72): the 114 // LOWEST, ascending. Assert.Equal((ushort)(4 + 114 * 4), header.DataSize); Assert.Equal( 114u, BinaryPrimitives.ReadUInt32LittleEndian( nak.AsSpan(PacketHeader.Size))); for (int i = 0; i < 114; i++) { Assert.Equal( (uint)(3 + i), BinaryPrimitives.ReadUInt32LittleEndian( nak.AsSpan(PacketHeader.Size + 4 + i * 4))); } Assert.Equal(200, transport.Inbound.NakCount); } // ===================================================================== // The AD-51 reclaimed-word pool — tracker-level proofs. The shadow // ISAAC is seeded identically; its draw order is the SERVER's word // assignment (the server never draws for a cleartext reject). // ===================================================================== [Fact] public void RejectInOrder_OwnMisparkedWord_FeedsTheNextFreshDraw() { (InboundSequenceTracker tracker, _) = CreateTracker(9); IsaacRandom shadow = MakeIsaac(TrackerSeed); uint a = shadow.Next(); uint b = shadow.Next(); uint c = shadow.Next(); // ACE flushes a reject at fresh cleartext sequence 10 (no word), // then encrypted 11, 12, 13 sealed with a, b, c. The reject // arrives first, in order: the cleartext borrowed-id walk parks a // word for 10 itself — the mis-park. Assert.False(tracker.Admit(10, encrypted: false).Drop); Assert.Equal(1, tracker.NakCount); tracker.OnCleartextRejectSequence(10); Assert.Equal(0, tracker.NakCount); Assert.Equal(1, tracker.ReclaimedWordCount); // WITHOUT the pool, 11 would draw the second word (b) and fail // forever — the one-word-ahead desync. With it, 11 takes the // reclaimed word (a): exactly the position ACE's stream sits on. Assert.Equal(a, Admitted(tracker, 11)); Assert.Equal(0, tracker.ReclaimedWordCount); Assert.Equal(b, Admitted(tracker, 12)); Assert.Equal(c, Admitted(tracker, 13)); Assert.Equal(0, tracker.NakCount); } [Fact] public void RejectAfterHigherArrival_BubbleRealignsTheParkedChain() { (InboundSequenceTracker tracker, _) = CreateTracker(9); IsaacRandom shadow = MakeIsaac(TrackerSeed); uint a = shadow.Next(); uint b = shadow.Next(); uint c = shadow.Next(); // ACE: reject at 10 (cleartext, no word), then encrypted 11 sealed // with a. The reject is delayed; 11 arrives FIRST: the gap walk // parks a for the missing 10 and hands 11 the word b — which fails // verification (ACE sealed 11 with a), so the session re-parks b // beside 11. InboundSequenceTracker.Admission eleven = tracker.Admit(11, encrypted: true); Assert.Equal(b, eleven.VerifyKey); tracker.ReparkKey(11, b, eleven.VerifyKeyDrawOrder); Assert.Equal(2, tracker.NakCount); // 10 and 11 both parked // The reject at 10 arrives late (cleartext, below the watermark). // The reclaim removes 10's mis-park AND bubbles the chain above it // down one word: 11's parked word becomes a (its true word), and // the excess b joins the pool. Assert.False(tracker.Admit(10, encrypted: false).Drop); tracker.OnCleartextRejectSequence(10); Assert.Equal(1, tracker.NakCount); Assert.Equal(1, tracker.ReclaimedWordCount); // The retransmission of 11 decodes with its TRUE word. Assert.Equal(a, Admitted(tracker, 11)); Assert.Equal(0, tracker.NakCount); // And the next fresh packet takes the pooled b, then the stream // continues on c — full realignment. Assert.Equal(b, Admitted(tracker, 12)); Assert.Equal(0, tracker.ReclaimedWordCount); Assert.Equal(c, Admitted(tracker, 13)); } [Fact] public void RejectBodyIds_StayDiscarded_OnlyTheOwnSequenceReclaims() { (InboundSequenceTracker tracker, _) = CreateTracker(9); IsaacRandom shadow = MakeIsaac(TrackerSeed); uint s1 = shadow.Next(); uint s2 = shadow.Next(); uint s3 = shadow.Next(); uint s4 = shadow.Next(); uint s5 = shadow.Next(); // Encrypted 10, 11 (sealed s1, s2) are lost; encrypted 12 (s3) // arrives: parks s1 beside 10, s2 beside 11, decodes with s3. Assert.Equal(s3, Admitted(tracker, 12)); Assert.Equal(2, tracker.NakCount); // ACE pruned 10 and 11; the reject arrives at fresh cleartext // sequence 13 listing them. The BODY ids are the word-bearing // case: their words were drawn on both sides, so the parked keys // are consumed-in-place — discarded, never pooled (N2 behavior, // unchanged). Only the reject's OWN sequence reclaims. Assert.False(tracker.Admit(13, encrypted: false).Drop); Span ids = stackalloc byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(ids, 10u); BinaryPrimitives.WriteUInt32LittleEndian(ids.Slice(4), 11u); tracker.OnRejectRetransmit(ids, count: 2); tracker.OnCleartextRejectSequence(13); Assert.Equal(0, tracker.NakCount); Assert.Equal(1, tracker.ReclaimedWordCount); // s4 — 13's mis-park // ACE's stream: s1, s2 consumed by the pruned 10, 11; s3 by 12; // its next encrypted packet (14) seals with s4 — our pooled word. Assert.Equal(s4, Admitted(tracker, 14)); Assert.Equal(s5, Admitted(tracker, 15)); Assert.Equal(0, tracker.ReclaimedWordCount); } [Fact] public void TwoInterleavedRejects_InOrder_PoolPreservesAlignment() { (InboundSequenceTracker tracker, _) = CreateTracker(9); IsaacRandom shadow = MakeIsaac(TrackerSeed); uint s1 = shadow.Next(); uint s2 = shadow.Next(); // Rejects at fresh cleartext 10 and 11, then encrypted 12, 13 // (ACE seals them with s1, s2 — it drew nothing for the rejects). Assert.False(tracker.Admit(10, encrypted: false).Drop); tracker.OnCleartextRejectSequence(10); Assert.False(tracker.Admit(11, encrypted: false).Drop); // 11's own park consumed the pooled word back; reclaiming it // returns it to the pool — depth stays one through the pair. tracker.OnCleartextRejectSequence(11); Assert.Equal(0, tracker.NakCount); Assert.Equal(1, tracker.ReclaimedWordCount); Assert.Equal(s1, Admitted(tracker, 12)); Assert.Equal(s2, Admitted(tracker, 13)); Assert.Equal(0, tracker.ReclaimedWordCount); } [Fact] public void TwoInterleavedRejects_Crossed_PoolDrainsInDrawOrder() { (InboundSequenceTracker tracker, _) = CreateTracker(9); IsaacRandom shadow = MakeIsaac(TrackerSeed); uint s1 = shadow.Next(); uint s2 = shadow.Next(); uint s3 = shadow.Next(); uint s4 = shadow.Next(); uint s5 = shadow.Next(); // ACE: rejects at 10 and 11 (no words), encrypted 12, 13 sealed // with s1, s2. Both encrypted packets arrive FIRST: the walk parks // s1, s2 beside the rejects; 12 and 13 draw s3, s4 — both fail // verification (true words s1, s2) and re-park. InboundSequenceTracker.Admission twelve = tracker.Admit(12, encrypted: true); Assert.Equal(s3, twelve.VerifyKey); tracker.ReparkKey(12, s3, twelve.VerifyKeyDrawOrder); InboundSequenceTracker.Admission thirteen = tracker.Admit(13, encrypted: true); Assert.Equal(s4, thirteen.VerifyKey); tracker.ReparkKey(13, s4, thirteen.VerifyKeyDrawOrder); Assert.Equal(4, tracker.NakCount); // The rejects arrive OUT of order — 11 first, then 10. Each // reclaim bubbles the chain above it down one word. A naive FIFO // would hand the excess words out in push order (s4 then s3); // draw-order consumption is what keeps the pool aligned. Assert.False(tracker.Admit(11, encrypted: false).Drop); tracker.OnCleartextRejectSequence(11); Assert.False(tracker.Admit(10, encrypted: false).Drop); tracker.OnCleartextRejectSequence(10); Assert.Equal(2, tracker.NakCount); // 12, 13 remain Assert.Equal(2, tracker.ReclaimedWordCount); // s3, s4 // The retransmissions decode with their TRUE words (the bubble // put s1 beside 12 and s2 beside 13). Assert.Equal(s1, Admitted(tracker, 12)); Assert.Equal(s2, Admitted(tracker, 13)); Assert.Equal(0, tracker.NakCount); // Fresh packets drain the pool lowest-draw-order first: s3, then // s4, then the wheel continues at s5. Assert.Equal(s3, Admitted(tracker, 14)); Assert.Equal(s4, Admitted(tracker, 15)); Assert.Equal(0, tracker.ReclaimedWordCount); Assert.Equal(s5, Admitted(tracker, 16)); } // ===================================================================== // Conformance against the N0 ACE-behaviour double (real WorldSession) // ===================================================================== /// /// The N4 win end-to-end: a real S2C loss now RECOVERS without test /// hooks — the 0.6 s sweep NAKs the parked id, the model serves the /// Retransmission from its cache, the parked key decodes it, the set /// empties, and the cumulative ack resumes 2.0 s after the NAK. /// [Fact] public void S2CLoss_NakRoundTrip_ModelRetransmission_AcksResume() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); session.TransportClockSource = (transport.Clock.GetTimestamp, transport.Clock.Frequency); 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); // The link eats the datagram carrying "lost"; "marker" opens // the gap and parks the missing id's key. transport.Link.DropNext(LinkDirection.ServerToClient); transport.Model.EnqueueGameMessage( BuildServerMessage("lost"), GameMessageGroup.UIQueue); transport.PumpServer(); transport.Model.EnqueueGameMessage( BuildServerMessage("marker"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => session.Transport!.Inbound.NakCount == 1); PumpUntil(session, () => messages.Contains("marker")); long naksBefore = session.Transport!.Stats.NaksSent; long acksBefore = session.Transport.Stats.AcksSent; int servedBefore = transport.Model.RetransmitsServed; // Past the 0.6 s gate the sweep emits the NAK; the model // serves the cached datagram immediately (NetworkSession // Retransmit :675-708 — cache-entry flags gain Retransmission, // the ORIGINAL IsaacXor is reused). transport.Clock.Advance(TimeSpan.FromSeconds(0.7)); session.Tick(); Assert.Equal(naksBefore + 1, session.Transport.Stats.NaksSent); Assert.Equal(servedBefore + 1, transport.Model.RetransmitsServed); bool sawRetransmission = false; foreach (byte[] datagram in transport.Model.SentDatagrams) { if ((PacketHeader.Unpack(datagram).Flags & PacketHeaderFlags.Retransmission) != 0) { sawRetransmission = true; } } Assert.True(sawRetransmission); // The parked key decodes the retransmission; the message // dispatches and the set empties. PumpUntil(session, () => messages.Contains("lost")); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); Assert.Equal(0, transport.Model.CrcDropCount); // The ack resumes 2.0 s after the NAK's stamp of the shared // timestamp. transport.Clock.Advance(TimeSpan.FromSeconds(2.1)); session.Tick(); Assert.True(session.Transport.Stats.AcksSent > acksBefore); Assert.False(transport.Model.IsTerminated); } finally { session.Dispose(); } } /// /// Prompt test (a)+(b) at system level: a sustained one-way S2C outage /// ages the lost id past ACE's 120 s cache retention (C2S game traffic /// keeps the session alive — NAKs never refresh ACE's timeout), so the /// healed link delivers RejectRetransmits at FRESH cleartext sequences. /// Every reject's own mis-parked word is reclaimed (including /// retransmitted rejects for the ones lost during the outage), the /// abandoned id inside the reject bodies stays discarded, and later /// encrypted traffic decodes until the pool drains to zero — the /// permanent one-word-ahead desync this slice exists to prevent. /// [Fact] public void PrunedId_RejectAtFreshSequence_ReclaimKeepsTheStreamAligned() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); session.TransportClockSource = (transport.Clock.GetTimestamp, transport.Clock.Frequency); 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); // Open the gap: "victim" is eaten, "marker" parks its id. transport.Link.DropNext(LinkDirection.ServerToClient); transport.Model.EnqueueGameMessage( BuildServerMessage("victim"), GameMessageGroup.UIQueue); transport.PumpServer(); transport.Model.EnqueueGameMessage( BuildServerMessage("marker"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => session.Transport!.Inbound.NakCount == 1); // Total S2C outage. Our NAKs still reach the model (it serves // the cached victim into the void every time) and per-step C2S // chat keeps refreshing ACE's 60 s deadline — NAKs deliberately // never do (NetworkSession.cs:283-308 returns before the // refresh). bool s2cBlocked = true; transport.Link.Drop( LinkDirection.ServerToClient, (_, _) => s2cBlocked); // 130 virtual seconds in 0.5 s steps: the victim's cache entry // ages past the 120 s retention (prune runs every 5 s) and the // post-prune NAKs start drawing RejectRetransmits at fresh // sequences — several of them, all dropped, each consuming // another fresh sequence (the interleaved-reject shape). for (int step = 0; step < 260; step++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); session.SendTalk($"keepalive {step}"); transport.PumpServer(); session.Tick(); if ((step & 15) == 0) Thread.Sleep(1); } Assert.False(transport.Model.IsTerminated); Assert.True( transport.Model.RetransmitsServed > 0, "the outage should have served retransmits into the void"); // Heal. The next NAK sweep re-requests everything parked // across the outage window (the victim, the TimeSync ids, the // dropped rejects' fresh ids); the model retransmits its // cached entries — including the CACHED rejects — and answers // the pruned victim with a fresh reject. s2cBlocked = false; DateTime deadline = DateTime.UtcNow.AddSeconds(20); while (session.Transport!.Inbound.NakCount > 0 && DateTime.UtcNow < deadline) { transport.Clock.Advance(TimeSpan.FromMilliseconds(700)); transport.PumpServer(); session.Tick(); Thread.Sleep(1); } Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.True( session.Transport.Stats.RejectWordsReclaimed >= 1, "at least one reject fresh-sequence mis-park must have been reclaimed"); bool modelSentReject = false; foreach (byte[] datagram in transport.Model.SentDatagrams) { if ((PacketHeader.Unpack(datagram).Flags & PacketHeaderFlags.RejectRetransmit) != 0) { modelSentReject = true; } } Assert.True(modelSentReject); // The victim is ABANDONED, not recovered: its parked word was // discarded in place (the body-id half of AD-51). Assert.DoesNotContain("victim", messages); // THE PROOF the hazard is closed: encrypted traffic keeps // decoding until the reclaim pool fully drains, and beyond. // Without the pool, the first fresh draw after the reject // would sit one word ahead and every packet here would fail // its checksum. int drainTarget = session.Transport.Inbound.ReclaimedWordCount + 3; long checksumFailuresBefore = session.Transport.Stats.ChecksumFailures; for (int i = 0; i < drainTarget; i++) { transport.Model.EnqueueGameMessage( BuildServerMessage($"post-heal {i}"), GameMessageGroup.UIQueue); transport.PumpServer(); int expected = i; PumpUntil( session, () => messages.Contains($"post-heal {expected}")); } Assert.Equal( checksumFailuresBefore, session.Transport.Stats.ChecksumFailures); Assert.Equal(0, session.Transport.Inbound.ReclaimedWordCount); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(256, transport.Model.Crypto.Headroom); Assert.False(transport.Model.IsTerminated); } finally { session.Dispose(); } } /// /// Long-loss survival: a 10 s total S2C outage with a parked gap /// produces NAKs on the 0.6 s cadence and ZERO acks (mutual /// exclusivity), the model's 60 s timeout never fires, and when the /// loss heals inside the window the gap resolves and the acks resume. /// [Fact] public void LongLoss_NaksOnTheGateCadence_NoAcks_GapHealsInsideTheWindow() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); session.TransportClockSource = (transport.Clock.GetTimestamp, transport.Clock.Frequency); 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.Link.DropNext(LinkDirection.ServerToClient); transport.Model.EnqueueGameMessage( BuildServerMessage("lost"), GameMessageGroup.UIQueue); transport.PumpServer(); transport.Model.EnqueueGameMessage( BuildServerMessage("marker"), GameMessageGroup.UIQueue); transport.PumpServer(); PumpUntil(session, () => session.Transport!.Inbound.NakCount == 1); long naksBefore = session.Transport!.Stats.NaksSent; long acksBefore = session.Transport.Stats.AcksSent; // 10 s of total S2C loss in 0.25 s frames: the NAK cadence is // gate-quantized to every 0.75 s (the first frame past each // 0.6 s gate) — 13 emissions, zero acks. bool s2cBlocked = true; transport.Link.Drop( LinkDirection.ServerToClient, (_, _) => s2cBlocked); for (int step = 0; step < 40; step++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(250)); transport.PumpServer(); session.Tick(); if ((step & 7) == 0) Thread.Sleep(1); } long naksDuringWindow = session.Transport.Stats.NaksSent - naksBefore; Assert.InRange(naksDuringWindow, 11, 15); Assert.Equal(acksBefore, session.Transport.Stats.AcksSent); Assert.False(transport.Model.IsTerminated); // Heal: the next NAK round-trips, the parked key decodes the // retransmission, and the ack resumes 2.0 s after that NAK. s2cBlocked = false; DateTime deadline = DateTime.UtcNow.AddSeconds(15); while ((session.Transport.Inbound.NakCount > 0 || !messages.Contains("lost")) && DateTime.UtcNow < deadline) { transport.Clock.Advance(TimeSpan.FromMilliseconds(700)); transport.PumpServer(); session.Tick(); Thread.Sleep(1); } Assert.Contains("lost", messages); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); transport.Clock.Advance(TimeSpan.FromSeconds(2.1)); session.Tick(); Assert.True(session.Transport.Stats.AcksSent > acksBefore); Assert.False(transport.Model.IsTerminated); } finally { session.Dispose(); } } // ===================================================================== // The loss soak — the approved-plan capstone // ===================================================================== /// /// 2% seeded random loss in BOTH directions across 10,000 game /// messages (5,000 each way) on the virtual clock: zero message loss /// in either direction, the model's 256-key crypto headroom intact /// throughout (no C2S re-key or unrequested resend ever), and every /// transport ledger converged at the end — NAK set empty, reclaim pool /// empty, no pending resends, and the sent-packet cache holding at /// most the watermark entry (ACE acks the last RECEIVED sequence and /// retail's Flush prunes STRICTLY below it, so one entry is the /// retail-faithful steady state). The session survives the whole run. /// [Fact] public void LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); session.TransportClockSource = (transport.Clock.GetTimestamp, transport.Clock.Frequency); try { session.Connect( "testaccount", "testpassword", TimeSpan.FromSeconds(10)); session.EnterWorld(0, TimeSpan.FromSeconds(10)); int s2cReceived = 0; session.ServerMessageReceived += m => { if (m.Message.StartsWith("s2c ", StringComparison.Ordinal)) s2cReceived++; }; // Count the soak's own C2S messages by marker: the convergence // trickle below shares the dispatch stream, and a bare count // could hide one lost soak message behind one extra trickle // message. int c2sDispatched = 0; transport.Model.MessageDispatched += body => { if (body.AsSpan().IndexOf("c2s "u8) >= 0) c2sDispatched++; }; transport.Link.RandomLoss( LinkDirection.ClientToServer, 0.02, seed: 0x5EED0001); transport.Link.RandomLoss( LinkDirection.ServerToClient, 0.02, seed: 0x5EED0002); const int MessagesEachWay = 5_000; for (int i = 0; i < MessagesEachWay; i++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(25)); session.SendTalk($"c2s {i}"); transport.Model.EnqueueGameMessage( BuildServerMessage($"s2c {i}"), GameMessageGroup.UIQueue); transport.PumpServer(); session.Tick(); if ((i & 15) == 0) Thread.Sleep(1); if (i % 500 == 0) { // The C2S keystream discipline holds the whole way: // resends reuse their original key and nothing resends // unrequested, so ACE's search window never ERODES. A // sample can land while a loss is mid-heal — the lost // packet's key sits parked in ACE's xors set until our // resend un-parks it (CryptoSystem.cs:19-29), so the // instantaneous floor is a handful below 256; the // exact-256 pin is the converged assert below. Assert.True( transport.Model.Crypto.Headroom >= 250, $"headroom {transport.Model.Crypto.Headroom} at " + $"message {i} — the search window is eroding"); Assert.False(transport.Model.IsTerminated); } } // Convergence phase 1: keep a C2S trickle flowing until every // soak message has landed on both sides. ACE's NAK is // ARRIVAL-driven (desired + 2 <= arrived, 1 s rate limit — // campaign §3 row 1: "a quiet client is never NAKed", and our // exact-flags acks are exempt from its gap detection), so a // burst tail lost just before the client goes quiet is only // recoverable once further sequenced C2S traffic arrives. Real // clients keep talking; the trickle models that. int trickle = 0; DateTime deadline = DateTime.UtcNow.AddSeconds(60); while (DateTime.UtcNow < deadline && (s2cReceived != MessagesEachWay || c2sDispatched != MessagesEachWay)) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); session.SendTalk($"trickle {trickle++}"); transport.PumpServer(); session.Tick(); Thread.Sleep(1); } // Convergence phase 2: quiet drain. Acks keep flowing on the // virtual clock and the ledgers empty out. The one wrinkle is // a trickle tail lost on the wire: ACE never NAKs a quiet // client, so a stuck cache entry needs fresh sequenced C2S // traffic (two arrivals) before ACE's gap detection can fire — // an occasional healer send, only while the cache is stuck. int quietIterations = 0; while (DateTime.UtcNow < deadline) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); if (session.Transport!.Outbound.CacheDepth > 1 && ++quietIterations % 8 == 0) { session.SendTalk($"trickle {trickle++}"); } transport.PumpServer(); session.Tick(); Thread.Sleep(1); if (s2cReceived == MessagesEachWay && c2sDispatched == MessagesEachWay && session.Transport.Inbound.NakCount == 0 && session.Transport.Inbound.ReclaimedWordCount == 0 && session.Transport.Outbound.PendingResendCount == 0 && session.Transport.Outbound.CacheDepth <= 1 && transport.Model.OutOfOrderPacketCount == 0) { break; } } // Zero message loss, both directions. string ledger = $"s2c={s2cReceived} " + $"c2s={c2sDispatched} " + $"nak-set={session.Transport!.Inbound.NakCount} " + $"reclaim={session.Transport.Inbound.ReclaimedWordCount} " + $"pending={session.Transport.Outbound.PendingResendCount} " + $"cache={session.Transport.Outbound.CacheDepth} " + $"ooo={transport.Model.OutOfOrderPacketCount} " + $"fraggate={transport.Model.FragmentGateBufferCount} " + $"cksumfail={session.Transport.Stats.ChecksumFailures} " + $"dups={session.Transport.Stats.InboundDupsDropped} " + $"naks-sent={session.Transport.Stats.NaksSent} " + $"resends={session.Transport.Stats.ResendsSent} " + $"served={transport.Model.RetransmitsServed} " + $"headroom={transport.Model.Crypto.Headroom} " + $"terminated={transport.Model.IsTerminated}"; Assert.True( s2cReceived == MessagesEachWay, $"S2C loss: {ledger}"); Assert.True( c2sDispatched == MessagesEachWay, $"C2S loss: {ledger}"); // The loss was real and both recovery directions fired. Assert.True( transport.Link.DroppedCount(LinkDirection.ClientToServer) > 0); Assert.True( transport.Link.DroppedCount(LinkDirection.ServerToClient) > 0); Assert.True(session.Transport!.Stats.ResendsSent > 0); Assert.True(session.Transport.Stats.NaksSent > 0); Assert.True(transport.Model.RetransmitsServed > 0); // Ledgers converged. Assert.Equal(256, transport.Model.Crypto.Headroom); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(0, session.Transport.Inbound.ReclaimedWordCount); Assert.Equal(0, session.Transport.Outbound.PendingResendCount); Assert.True( session.Transport.Outbound.CacheDepth <= 1, $"cache depth {session.Transport.Outbound.CacheDepth} — " + "only the watermark entry may remain"); Assert.Equal(0, transport.Model.OutOfOrderPacketCount); Assert.Equal(0, transport.Model.FragmentGateBufferCount); Assert.Equal(0, session.Transport.Stats.ChecksumFailures); // Alive at the end. Assert.False(transport.Model.IsTerminated); Assert.Equal(WorldSession.State.InWorld, session.CurrentState); } finally { session.Dispose(); } } // ===================================================================== // Fixture helpers // ===================================================================== private static (ReliableTransport Transport, VirtualClock Clock, List Sent) CreateTransport() { var virtualClock = new VirtualClock(); var sent = new List(); var transport = new ReliableTransport( MakeIsaac(ClientSeed), MakeIsaac(ServerSeed), (ushort)ClientId, SessionIteration, datagram => sent.Add(datagram.ToArray()), new TransportClock( virtualClock.GetTimestamp, virtualClock.Frequency)); return (transport, virtualClock, sent); } private static (InboundSequenceTracker Tracker, TransportStats Stats) CreateTracker(uint initialWatermark) { var stats = new TransportStats(); return ( new InboundSequenceTracker( MakeIsaac(TrackerSeed), stats, initialWatermark), stats); } private static void Admit(ReliableTransport transport, uint sequence) { InboundSequenceTracker.Admission admission = transport.Inbound.Admit(sequence, encrypted: true); Assert.False(admission.Drop); } 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 byte[] MakeMessage(byte marker) => new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 }; private static IsaacRandom MakeIsaac(uint seed) { Span seedBytes = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); return new IsaacRandom(seedBytes); } 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(); } }