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>
1017 lines
43 KiB
C#
1017 lines
43 KiB
C#
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();
|
||
}
|
||
}
|