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();
}
}