acdream/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs
Erik 852a59e388 feat(net): N4 - client NAK emission + RejectRetransmit reclaim
Campaign N slice N4 completes the AckNakScheduler NAK branch and closes
the ACE cleartext-reject keystream hazard - the slice that makes S2C
loss actually RECOVER.

NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0):
- One cleartext exact-flags RequestRetransmit per sweep behind the
  STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test
  at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays
  >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s
  and vice versa (landmine #7).
- Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks
  @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E);
  header Sequence borrowed from highestIDSent_ without incrementing;
  cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) -
  and a NAK never refreshes ACE's 60 s timeout.
- Control-header rule decided once for BOTH ack and NAK: Time = the
  interval id, Iteration = the session iteration, matching retail's
  shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the
  stack build at 0x00547A84). ACE reads neither field inbound.
- Gate ticks now round instead of truncate: 0.6 has no exact double
  form, and truncation opened the strict gate exactly AT the boundary.

RejectRetransmit reclaim (divergence register AD-51, ACE adaptation):
- ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO
  keystream word, and is cached (ACE NetworkSession.cs:299-304,
  :722-725, :743-748) - the one place ACE breaks retail's gap-walk
  invariant that every missing id was word-bearing (retail cleartext
  always borrows live sequences). Unhandled, the gap walk parks a word
  for the reject's id and the inbound stream runs permanently one word
  ahead - the N2 desync class reintroduced through the reject path.
- Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes
  the mis-park, shifts every later-drawn parked word down one position
  (per-word draw ordinals; ascending wrap-safe id <=> ascending draw
  order), and pools the excess word, consumed lowest-draw-order-first
  ahead of fresh ISAAC draws. Exact for any number of interleaved
  rejects in ANY arrival order - a plain reclaim FIFO is not: a reject
  arriving after a higher encrypted arrival crosses the parked chain,
  and two out-of-order rejects pool their excess words out of draw
  order (both orderings pinned by tests).
- Reject BODY ids keep N2's discard: word-bearing server-side,
  consumed-in-place. The pool is provably empty against retail servers.

N3 advisories folded (all five): honest transitional-state wording (the
empty N3 NAK branch could silently disconnect a loopback session at
ACE's 60 s timeout, witness [net-tick] acks/s=0), the
ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0)
citation, the FlowQueue::Empty pump-order wording (TransmitNaks ->
TransmitAcks -> TransmitNewPackets with the interval increment LAST @
0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the
Time/Iteration rule above, and the stale WorldSession budget-break
comment rewritten to the sweep reality.

Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3
pins): strict-gate boundary, shared timestamp both directions,
NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served
retransmission round trip, five tracker reclaim proofs, the 130 s
virtual prune -> fresh-sequence reject system test (victim abandoned,
later traffic decodes, pool drains to zero), 10 s long-loss survival
(NAKs on the gate cadence, zero acks, heal inside the window), and the
capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero
message loss both ways, ACE crypto headroom 256 at convergence, every
ledger drained (cache at the single watermark entry - retail's Flush
prunes STRICTLY below the ack). Full solution Release: 9,758 passed /
5 skipped. Connected world-lifecycle gate PASS
(logs/connected-world-gate-20260729-150238); canonical nine-stop soak
PASS (logs/connected-r6-soak-20260729-150856).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 15:20:35 +02:00

1017 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Campaign N Slice N4 — client NAK emission + RejectRetransmit
/// consumption: the 0.6 s STRICT gate on the shared timestamp
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>, the 0x41-mask test at
/// 0x00543C03), the ≤114-id ascending cleartext <c>RequestRetransmit</c>
/// (<c>ReceiverData::GetNaks @ 0x005490C0</c>, cap 0x72), and the AD-51
/// reclaimed-word pool that closes the ACE cleartext-reject keystream
/// hazard (ACE's <c>RejectRetransmit</c> 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).
/// </summary>
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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte> 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)
// =====================================================================
/// <summary>
/// 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.
/// </summary>
[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<string>();
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();
}
}
/// <summary>
/// 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.
/// </summary>
[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<string>();
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();
}
}
/// <summary>
/// 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.
/// </summary>
[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<string>();
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
// =====================================================================
/// <summary>
/// 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.
/// </summary>
[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<byte[]> Sent) CreateTransport()
{
var virtualClock = new VirtualClock();
var sent = new List<byte[]>();
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<byte> seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
return new IsaacRandom(seedBytes);
}
private static void PumpUntil(WorldSession session, Func<bool> 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();
}
}