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>
669 lines
27 KiB
C#
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);
|
|
}
|
|
}
|