Campaign N Slice N2 (docs/plans/2026-07-29-network-transport-campaign.md S2.2) - the second fatal #260 fix: the inbound keystream now aligns to SEQUENCE order instead of arrival order. One lost S2C datagram no longer desyncs the inbound cipher permanently - the missing id's pre-drawn key parks in the NAK set, later packets keep decoding, and the retransmission decodes with the parked key. New src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs - retail's ReceiverData inbound half, ported rule for rule: - Sanity window: drop when seq is wrap-safe newer than highestIDReceived_ + 0x7FFF (SharedNet::SeqIDSanityCheck @ 0x00543A20; the boundary itself is accepted). - Duplicate/late arrival (encrypted, at/below the watermark): NAK-set hit -> decrypt with the PARKED pre-drawn key; miss -> silent drop at ZERO keystream cost (SharedNet::ProcessNewSeqNum @ 0x00544690, the AVL::Remove branch) - the dup-word-burn and double-dispatch bugs close together. - Gap walk (SharedNet::ProcessNewestSeqNum @ 0x00541930): one inbound ISAAC word per missing id, drawn IN SEQUENCE ORDER BEFORE the arriving packet's own key (landmine #4), parked beside the id (ReceiverData::AddNakked @ 0x00549240, idempotent; id 0 skipped per retail's `if (esi_1 != 0)`). Cleartext walks to seq+1 - the borrowed id itself gets NAKed, so the real encrypted packet at that id can still decode later. - Verify-failure re-park: a sequenced encrypted checksum failure parks the consumed key back beside its id so the retransmission decodes (SharedNet::ProcessPacket @ 0x00544790 tail, AddNakked(seq, &key)). - Inbound RejectRetransmit -> silent NAK-set abandonment; parked keys discarded, alignment holds because the words were already drawn (SharedNet::HandleEmptyAck @ 0x005448F0). - NAK set = SortedDictionary<uint,uint> seq -> parked key; ascending raw-uint enumeration matches retail's AVL walk for N4's <=114-id NAK emission (ReceiverData::GetNaks @ 0x005490C0). PacketCodec split (campaign S4, retail's own factoring - the key is an optional in/out of ReceiverData::Decrypt): TryParseBorrowed is the pure parse + checksum-summand computation with NO keystream access anywhere; VerifyChecksum(header, headerHash, payloadHash, uint? key) compares the additive cleartext form (null) or headerHash + (key ^ payloadHash). TryDecodeBorrowed(datagram, IsaacRandom?) - the consume-before-compare site that WAS the bug - is deleted; the owned TryDecode stays (test-only). RejectRetransmit ids are now exposed on both decoders (borrowed RejectRetransmitBytes/Count like the Request pair; owned RejectRetransmits list); the bytes were always inside the hashed span, so parse-hash coverage is unchanged. WorldSession: ProcessDatagram head is now parse -> sequence-0 split (cleartext seq-0 = handshake/control, verified additively and processed as before; encrypted seq-0 dropped before any keystream access, like retail's ProcessPacket) -> tracker.Admit -> VerifyChecksum with the admission key -> failure re-park -> unchanged flag handling, N1 transport consumption, reflex ack, and fragment loop. The RejectRetransmit flag routes to the tracker beside the N1 NAK/ack consumption. The handshake Connect loop moved to parse + cleartext-verify (no tracker exists before ISAAC seeding; the ConnectRequest is cleartext seq 0). ReliableTransport now takes both Isaacs and exposes Inbound; the session's _inboundIsaac field is deleted. No production caller constructed the N1 ctor outside WorldSession, so no compatibility shape was kept. TransportStats gains InboundDupsDropped, InboundSanityDrops, ChecksumFailures, KeysParked (unconditional, like the N1 counters). Watermark init = 1 is an ACE adaptation, register row AD-50 (watermark INIT only, not a mechanism change; AD-49 stays reserved for the campaign S5 blob-layer deferral): retail zero-inits ReceiverData, but ACE never emits S2C sequence 1 - PacketSequence starts unprimed at uint.MaxValue, the cleartext ConnectRequest takes NextValue 0, and the first ENCRYPTED flush re-primes CurrentValue to 1 so the first encrypted sequenced packet is 2 (ACE NetworkSession.cs:716-717 resolving to UIntSequence(startingValue: 1), Sequence/UIntSequence.cs:9-13,30-41). A zero-init watermark would gap-walk the permanent id-1 hole: one spurious NAK, the first pre-drawn word mis-assigned to id 1, and the keystream off by one from the first encrypted packet onward. holtburger seeds the same value (crates/holtburger-session/src/session/api.rs:30, last_server_seq: 1), mirroring ACE's own C2S-side lastReceivedPacketSequence = 1 (NetworkSession.cs:57). The N0 model's dance is pinned by the clean-lifecycle conformance test: min encrypted S2C sequence == 2, zero NAKs, zero spurious drops. Tests (+14; Core.Net 702 -> 716): the decisive gap test (10,11,13,14 - 13 and 14 decode with fresh words while 12's key parks with KeysParked=1/NakCount=1, the late 12 decodes with the parked key, 15 takes the next fresh word - impossible pre-N2), zero-cost duplicate drop (shadow ISAAC position unchanged), re-park -> byte-identical retransmission decode, the cleartext borrowed-id rule, cleartext at the watermark (no NAK/key/watermark change), sanity boundary +0x7FFF accepted / +0x8000 dropped wrap-safe, skip-id-0 across the 32-bit wrap with ascending NAK enumeration, RejectRetransmit abandonment with alignment held, warm zero-alloc Admit; plus four real-WorldSession conformance runs against the N0 ACE double: clean lifecycle (zero NAKs at every stage), S2C loss of one packet of a Count=2 fragment set (later packets STILL decode - the N2 win; late byte-identical redelivery completes the split message intact), duplicate delivery dropped BEFORE dispatch, and the seq-0 tracker bypass. N3/N4 handoff notes are recorded in the campaign S9 N2 row: the interim per-packet reflex ack acks the arriving sequence even while a gap is parked (ACE prunes the lost id from its S2C cache before N4 could NAK it - message recovery needs N3's retail NAK-xor-ack sweep), and ACE's RejectRetransmit consumes a fresh CLEARTEXT sequence with no keystream word, an ACE-vs-retail wrinkle N4's design must resolve. Gates: dotnet build green; AcDream.Core.Net.Tests 716/716; full-solution Release 9,732 passed / 5 skipped / 0 failed; connected world-lifecycle gate vs local ACE RESULT=PASS (zero failures, one pre-existing expected world-edge landblock-miss warning); canonical nine-stop connected route RESULT=PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
664 lines
26 KiB
C#
664 lines
26 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) and drops.
|
|
Assert.Equal(w10, Admitted(tracker, 10));
|
|
tracker.ReparkKey(10, w10);
|
|
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);
|
|
}
|
|
}
|