acdream/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.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

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