Campaign N Slice N5 (docs/plans/2026-07-29-network-transport-campaign.md section 8 rung 3): the permanent removal of the loopback blindness that let #260 ship. Local ACE never drops a datagram, so every historical connected gate was structurally incapable of exercising the N1-N4 recovery machinery; from this slice on, tools/run-connected-loss-gate.ps1 runs the standard lifecycle route through deterministic seeded loss and passes only on proven non-zero recovery. Observability: - [net-tick] gains resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s reclaim/s cache= nakset= - TransportStats window deltas mirroring the acks/s cumulative-delta pattern, plus the two instantaneous depths (the unbounded-like-retail sent-packet cache watchdog and the inbound NAK set). TransportStats gains RejectsReceived (inbound RejectRetransmit packets). Counters increment unconditionally; every string is behind NetDiagnostics.ProbeNet (Code Structure Rule 5). - WorldSession.Dispose emits one cumulative [net-final] totals line so the loss gate asserts exact counters instead of reconstructing them from rounded per-second rates. - LinkStatusSnapshot.PacketLossPercentage is deliberately NOT wired: filed #261 - retail's CLinkStatusAverages formula (LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) must be located first; inventing a ratio is forbidden. N4-review F3 fold-in: - Fresh reliable sends stamp Header.Iteration = the session iteration through the same shared retail header build already cited for Time (N3) and the N4 control packets: FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84/0x00547AA8. The control-header rule now holds across all three send shapes (fresh reliable, ack, NAK). ACE reads neither Time nor Iteration inbound (campaign section 3) - wire-safe, and resends keep the stamp verbatim per the N1 rebuild rule. Loss injection (Transport/LossyTransportDecorator): - IWorldSessionTransport wrapper with deterministic seeded per-direction loss. Config via NetDiagnostics typed env properties read once: ACDREAM_NET_DROP_PCT (0 = off = default), ACDREAM_NET_DROP_SEED (default 1), ACDREAM_NET_DROP_DIR (out|in|both, default both). - Arming gate: NOTHING drops in either direction until the decorator has FORWARDED the first ENCRYPTED outbound datagram - parse-free check on length > 20 with EncryptedChecksum set in the LE flags word at bytes 4..8. The cleartext handshake always survives and the arming datagram is never a casualty; handshake-loss testing belongs to N6's ConnectResponse 0.333 s retransmit. - Structurally absent at 0%: WrapIfConfigured returns the raw transport - WorldSession's default factory is the only production seam and a normal run never constructs the decorator. Root-cause fix the gate immediately exposed: - The logoff-confirmation wait in Dispose processed inbound datagrams but never pumped the transport, so a lost S2C logoff confirmation was gap-detected but its healing NAK never went out. Retail's pump (Client::UseTime @ 0x00411C40 -> PacketController::UseTime @ 0x005410D0) runs until LogOffServer; the wait now sweeps per processed datagram, making the logoff wait the third covered blocking pump (after Tick and the handshake loops). A lost C2S logoff REQUEST remains unrecoverable by ACE design (arrival-driven NAK; a quiet client is never NAKed - campaign section 3 row 1), recorded in the gate header. Gates: - tools/run-connected-loss-gate.ps1 (-DropPct 2 -Seed 1): PASS vs local ACE - the first automated observation of packet loss in project history. Decorator ledger: dropped out=3 in=10 of forwarded out=183 in=496. [net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114 acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0 uncached-nak=0 cache=1 nakset=0. Every injected loss healed: both ACE-driven C2S resend recovery (nak-in=2 -> resends=2) and client-driven S2C NAK recovery (parked=9 -> nak-out=6) fired on a real connected route, all six checkpoints validated, graceful logout confirmed, ACE recorded the transport Disconnect. - tools/run-connected-world-lifecycle-gate.ps1 (decorator absent): PASS - zero behavior change on the no-loss baseline; the gate now defensively clears the drop env vars. - Core.Net Release: 747/747 (737 + 10 N5: decorator determinism/direction/ arming/structural-absence/env parsing, the 5% seeded WorldSession lossy lifecycle with zero message loss both ways + ACE Headroom 256, the [net-tick] field pins, the Iteration stamps). - Full solution Release: 9,763 passed / 5 skipped / 0 failed. Test-fixture note: FakeAceTransport gains AutoAdvanceOnBlockingReceive so virtual time can move during the blocking Connect()/EnterWorld() pumps - with the clock frozen there, a dropped handshake-window datagram could never be NAK-healed (a fixture artifact, not a transport property). Campaign section 9 ledger row added (SHA recorded at N6 kickoff). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
708 lines
29 KiB
C#
708 lines
29 KiB
C#
using System.Buffers;
|
||
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 N1 — the outbound sent-packet cache + resend on NAK.
|
||
/// Unit tests pin the store/clock/sequence primitives; conformance tests
|
||
/// grade the resend against the N0 ACE-behaviour double (the rebuilt-header
|
||
/// resend must verify under <see cref="AceCryptoModel"/> with the ORIGINAL
|
||
/// keystream word — campaign landmines #1/#2).
|
||
/// </summary>
|
||
public sealed class OutboundReliableTransportTests
|
||
{
|
||
private const uint ClientSeed = 0x11AA22BBu;
|
||
private const uint ServerSeed = 0x33CC44DDu;
|
||
private const uint ClientId = 0x1234u;
|
||
private const ushort SessionIteration = 0x0007;
|
||
private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
|
||
|
||
// =====================================================================
|
||
// SequenceMath — TimeStampUtils::lhs_newer @ 0x00543890
|
||
// =====================================================================
|
||
|
||
[Fact]
|
||
public void SequenceMath_IsNewer_IsWrapSafe()
|
||
{
|
||
Assert.True(SequenceMath.IsNewer(2u, 1u));
|
||
Assert.False(SequenceMath.IsNewer(1u, 2u));
|
||
Assert.False(SequenceMath.IsNewer(7u, 7u));
|
||
|
||
// Across the 32-bit wrap: 1 is newer than 0xFFFFFFFF.
|
||
Assert.True(SequenceMath.IsNewer(1u, uint.MaxValue));
|
||
Assert.False(SequenceMath.IsNewer(uint.MaxValue, 1u));
|
||
|
||
Assert.Equal(5u, SequenceMath.Max(5u, 3u));
|
||
Assert.Equal(5u, SequenceMath.Max(3u, 5u));
|
||
// Wrap-safe max: a small post-wrap value beats a huge pre-wrap one.
|
||
Assert.Equal(5u, SequenceMath.Max(0xFFFFFFF6u, 5u));
|
||
}
|
||
|
||
// =====================================================================
|
||
// TransportClock — ClientFlowQueue::IncrementLocalInterval @ 0x00547F10
|
||
// =====================================================================
|
||
|
||
[Fact]
|
||
public void TransportClock_StartsAtOne_AdvancesEveryHalfSecond_AndWraps()
|
||
{
|
||
var virtualClock = new VirtualClock();
|
||
var clock = new TransportClock(
|
||
virtualClock.GetTimestamp,
|
||
virtualClock.Frequency);
|
||
Assert.Equal((ushort)1, clock.IntervalId);
|
||
|
||
// Under half a second: no advance.
|
||
virtualClock.Advance(TimeSpan.FromSeconds(0.49));
|
||
clock.Update();
|
||
Assert.Equal((ushort)1, clock.IntervalId);
|
||
|
||
// Crossing 0.5 s advances one interval.
|
||
virtualClock.Advance(TimeSpan.FromSeconds(0.01));
|
||
clock.Update();
|
||
Assert.Equal((ushort)2, clock.IntervalId);
|
||
|
||
// A long gap advances by the whole number of elapsed intervals,
|
||
// preserving the fractional remainder.
|
||
virtualClock.Advance(TimeSpan.FromSeconds(2.75));
|
||
clock.Update();
|
||
Assert.Equal((ushort)7, clock.IntervalId);
|
||
virtualClock.Advance(TimeSpan.FromSeconds(0.25));
|
||
clock.Update();
|
||
Assert.Equal((ushort)8, clock.IntervalId);
|
||
|
||
// Natural ushort wrap: 65531 more intervals take 8 → 3 (mod 65536).
|
||
virtualClock.Advance(TimeSpan.FromSeconds(0.5 * 65531));
|
||
clock.Update();
|
||
Assert.Equal((ushort)3, clock.IntervalId);
|
||
}
|
||
|
||
// =====================================================================
|
||
// SentPacketStore — AddSentPacket @ 0x0054AB00 / Flush @ 0x0054ACD0
|
||
// =====================================================================
|
||
|
||
[Fact]
|
||
public void SentPacketStore_FifoContainsAndStrictFlush()
|
||
{
|
||
var pool = new CountingPool();
|
||
using var store = new SentPacketStore(pool);
|
||
store.Add(RentedEntry(pool, 2u), optionalLength: 0);
|
||
store.Add(RentedEntry(pool, 3u), optionalLength: 0);
|
||
store.Add(RentedEntry(pool, 4u), optionalLength: 0);
|
||
Assert.Equal(3, store.Count);
|
||
Assert.True(store.Contains(3u));
|
||
Assert.False(store.Contains(5u));
|
||
Assert.True(store.TryGet(2u, out SentPacketStore.CachedPacket got));
|
||
Assert.Equal(2u, got.Sequence);
|
||
|
||
// STRICTLY older: the watermark entry itself survives
|
||
// (SentPacketStore::Flush breaks at seqNum_ == watermark).
|
||
store.FlushOlderThan(3u);
|
||
Assert.Equal(2, store.Count);
|
||
Assert.False(store.Contains(2u));
|
||
Assert.True(store.Contains(3u));
|
||
Assert.True(store.Contains(4u));
|
||
Assert.Equal(1, pool.Returned);
|
||
|
||
store.FlushOlderThan(5u);
|
||
Assert.Equal(0, store.Count);
|
||
Assert.Equal(3, pool.Returned);
|
||
Assert.Equal(pool.Rented, pool.Returned);
|
||
}
|
||
|
||
[Fact]
|
||
public void SentPacketStore_FlushIsWrapSafe_AcrossTheSequenceWrap()
|
||
{
|
||
var pool = new CountingPool();
|
||
using var store = new SentPacketStore(pool);
|
||
// Retail wraps 0xFFFFFFFF → 1 (never 0).
|
||
store.Add(RentedEntry(pool, 0xFFFFFFFEu), optionalLength: 0);
|
||
store.Add(RentedEntry(pool, 0xFFFFFFFFu), optionalLength: 0);
|
||
store.Add(RentedEntry(pool, 1u), optionalLength: 0);
|
||
store.Add(RentedEntry(pool, 2u), optionalLength: 0);
|
||
|
||
// Watermark 1 (post-wrap): both pre-wrap entries are strictly
|
||
// older; 1 and 2 survive. A raw `<` compare would flush nothing.
|
||
store.FlushOlderThan(1u);
|
||
Assert.Equal(2, store.Count);
|
||
Assert.False(store.Contains(0xFFFFFFFEu));
|
||
Assert.False(store.Contains(0xFFFFFFFFu));
|
||
Assert.True(store.Contains(1u));
|
||
Assert.True(store.Contains(2u));
|
||
Assert.Equal(2, pool.Returned);
|
||
|
||
store.FlushOlderThan(3u);
|
||
Assert.Equal(0, store.Count);
|
||
Assert.Equal(pool.Rented, pool.Returned);
|
||
}
|
||
|
||
[Fact]
|
||
public void SentPacketStore_Dispose_ReturnsEveryRentedBuffer()
|
||
{
|
||
var pool = new CountingPool();
|
||
var store = new SentPacketStore(pool);
|
||
store.Add(RentedEntry(pool, 2u), optionalLength: 0);
|
||
store.Add(RentedEntry(pool, 3u), optionalLength: 0);
|
||
store.Dispose();
|
||
Assert.Equal(pool.Rented, pool.Returned);
|
||
}
|
||
|
||
[Fact]
|
||
public void SentPacketStore_Add_AssertsNoOptionalHeaders()
|
||
{
|
||
var pool = new CountingPool();
|
||
using var store = new SentPacketStore(pool);
|
||
SentPacketStore.CachedPacket entry = RentedEntry(pool, 2u);
|
||
Assert.Throws<InvalidOperationException>(
|
||
() => store.Add(entry, optionalLength: 4));
|
||
pool.Return(entry.Buffer); // the failed Add never took ownership
|
||
}
|
||
|
||
// =====================================================================
|
||
// OutboundFlowQueue — resend header rebuild (landmines #1/#2/#3)
|
||
// =====================================================================
|
||
|
||
[Fact]
|
||
public void Resend_RebuildsHeaderOnly_FlagsTimeChecksum_BodyBitIdentical()
|
||
{
|
||
(OutboundFlowQueue queue, VirtualClock virtualClock,
|
||
TransportClock clock, TransportStats stats, List<byte[]> sent) =
|
||
CreateQueue();
|
||
|
||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
|
||
byte[] original = Assert.Single(sent);
|
||
PacketHeader originalHeader = PacketHeader.Unpack(original);
|
||
Assert.Equal(2u, originalHeader.Sequence);
|
||
Assert.Equal(
|
||
PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
|
||
originalHeader.Flags);
|
||
// N3 fold-in: fresh sends stamp the current interval id (the clock
|
||
// starts at 1) — FlowQueue::TransmitNewPackets @ 0x00547A60.
|
||
Assert.Equal((ushort)1, originalHeader.Time);
|
||
// N5 fold-in (N4 review F3): fresh sends stamp the session iteration
|
||
// through the same shared header build (0x00547A84/0x00547AA8),
|
||
// completing the control-header rule across all three send shapes.
|
||
Assert.Equal(SessionIteration, originalHeader.Iteration);
|
||
|
||
// 1.2 s later (interval id 1 → 3) the server NAKs sequence 2.
|
||
virtualClock.Advance(TimeSpan.FromSeconds(1.2));
|
||
clock.Update();
|
||
Nak(queue, 2u);
|
||
sent.Clear();
|
||
queue.TransmitPendingResends();
|
||
|
||
byte[] resent = Assert.Single(sent);
|
||
PacketHeader resentHeader = PacketHeader.Unpack(resent);
|
||
|
||
// Flags become EXACTLY Retransmission|EncryptedChecksum|BlobFragments
|
||
// (= 7 with fragments); Time advances to the current interval;
|
||
// Sequence/Id/Iteration/DataSize stay verbatim.
|
||
Assert.Equal(
|
||
PacketHeaderFlags.Retransmission
|
||
| PacketHeaderFlags.EncryptedChecksum
|
||
| PacketHeaderFlags.BlobFragments,
|
||
resentHeader.Flags);
|
||
Assert.Equal((uint)7, (uint)resentHeader.Flags);
|
||
Assert.Equal((ushort)3, resentHeader.Time);
|
||
Assert.Equal(originalHeader.Sequence, resentHeader.Sequence);
|
||
Assert.Equal(originalHeader.DataSize, resentHeader.DataSize);
|
||
Assert.Equal(originalHeader.Id, resentHeader.Id);
|
||
Assert.Equal(originalHeader.Iteration, resentHeader.Iteration);
|
||
|
||
// Checksum = FRESH header hash + the stored sealed checksum, where
|
||
// sealed = originalChecksum − originalHeaderHash (landmine #1).
|
||
uint sealedChecksum =
|
||
originalHeader.Checksum - originalHeader.CalculateHeaderHash32();
|
||
Assert.Equal(
|
||
resentHeader.CalculateHeaderHash32() + sealedChecksum,
|
||
resentHeader.Checksum);
|
||
|
||
// Body bytes bit-identical.
|
||
Assert.Equal(
|
||
original.AsSpan(PacketHeader.Size).ToArray(),
|
||
resent.AsSpan(PacketHeader.Size).ToArray());
|
||
Assert.Equal(1, stats.ResendsSent);
|
||
Assert.Equal(1, stats.NakRequestsReceived);
|
||
Assert.Equal(0, stats.UncachedNakIds);
|
||
}
|
||
|
||
[Fact]
|
||
public void BuildResendHeader_WithoutFragments_FlagsAreExactlyThree()
|
||
{
|
||
// Fragmentless reliable packets do not exist on the N1 send path
|
||
// (every reliable message rides a fragment), but the rebuild rule is
|
||
// pinned for both shapes: 3 without fragments, 7 with.
|
||
byte[] buffer = new byte[PacketHeader.Size];
|
||
var header = new PacketHeader
|
||
{
|
||
Sequence = 9u,
|
||
Flags = PacketHeaderFlags.EncryptedChecksum,
|
||
Id = 0x1234,
|
||
DataSize = 0,
|
||
};
|
||
header.Pack(buffer);
|
||
var cached = new SentPacketStore.CachedPacket(
|
||
9u, buffer, bodyLength: 0, sealedChecksum: 0xDEADBEEFu,
|
||
isaacKey: 0u, hasFragments: false);
|
||
|
||
PacketHeader rebuilt =
|
||
OutboundFlowQueue.BuildResendHeader(in cached, intervalId: 42);
|
||
Assert.Equal(
|
||
PacketHeaderFlags.Retransmission | PacketHeaderFlags.EncryptedChecksum,
|
||
rebuilt.Flags);
|
||
Assert.Equal((uint)3, (uint)rebuilt.Flags);
|
||
Assert.Equal((ushort)42, rebuilt.Time);
|
||
Assert.Equal(9u, rebuilt.Sequence);
|
||
Assert.Equal(
|
||
rebuilt.CalculateHeaderHash32() + 0xDEADBEEFu,
|
||
rebuilt.Checksum);
|
||
}
|
||
|
||
[Fact]
|
||
public void Resend_ConsumesNoOutboundIsaacWord()
|
||
{
|
||
(OutboundFlowQueue queue, _, _, _, List<byte[]> sent) = CreateQueue();
|
||
IsaacRandom shadow = MakeIsaac(ClientSeed);
|
||
uint w1 = shadow.Next();
|
||
uint w2 = shadow.Next();
|
||
uint w3 = shadow.Next();
|
||
|
||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); // seq 2, w1
|
||
queue.SendGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); // seq 3, w2
|
||
Assert.Equal(w1, ExtractIsaacKey(sent[0]));
|
||
Assert.Equal(w2, ExtractIsaacKey(sent[1]));
|
||
|
||
// Resend of seq 2 reuses w1 — no keystream word drawn (landmine #2).
|
||
Nak(queue, 2u);
|
||
sent.Clear();
|
||
queue.TransmitPendingResends();
|
||
Assert.Equal(w1, ExtractIsaacKey(Assert.Single(sent)));
|
||
|
||
// The wheel did not move: the next fresh packet takes w3.
|
||
sent.Clear();
|
||
queue.SendGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); // seq 4, w3
|
||
Assert.Equal(w3, ExtractIsaacKey(Assert.Single(sent)));
|
||
}
|
||
|
||
[Fact]
|
||
public void Nak_ForUncachedId_SendsNothing_AndCounts()
|
||
{
|
||
(OutboundFlowQueue queue, _, _, TransportStats stats, List<byte[]> sent) =
|
||
CreateQueue();
|
||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); // seq 2
|
||
sent.Clear();
|
||
|
||
// Id 40 was never sent: dropped silently + counted (TS-57 — retail
|
||
// answers RejectRetransmit; ACE no-ops it and the standalone form
|
||
// would trip the watermark hole).
|
||
Nak(queue, 40u);
|
||
queue.TransmitPendingResends();
|
||
Assert.Empty(sent);
|
||
Assert.Equal(1, stats.UncachedNakIds);
|
||
Assert.Equal(0, stats.ResendsSent);
|
||
Assert.Equal(0, queue.PendingResendCount);
|
||
}
|
||
|
||
[Fact]
|
||
public void NakFirstId_FoldsTheAckWatermark_AndSweepPrunesStrictlyBelow()
|
||
{
|
||
(OutboundFlowQueue queue, _, _, TransportStats stats, List<byte[]> sent) =
|
||
CreateQueue();
|
||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); // seq 2
|
||
queue.SendGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); // seq 3
|
||
queue.SendGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); // seq 4
|
||
Assert.Equal(3, queue.CacheDepth);
|
||
sent.Clear();
|
||
|
||
// ids[0] = 4 doubles as the implicit cumulative ack
|
||
// (RecipientData::ProcessNaks @ 0x00547010): everything strictly
|
||
// below 4 prunes on the sweep; 4 itself is served.
|
||
Nak(queue, 4u);
|
||
Assert.Equal(4u, queue.AckWatermark);
|
||
queue.TransmitPendingResends();
|
||
Assert.Equal(4u, PacketHeader.Unpack(Assert.Single(sent)).Sequence);
|
||
Assert.Equal(1, queue.CacheDepth);
|
||
Assert.True(stats.AcksConsumed >= 1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// N3 fold-in of the N1 review advisory: retail stamps
|
||
/// <c>CurLocalInterval_.intervalID_</c> into <c>Header.Time</c> on every
|
||
/// FRESH packet (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, the
|
||
/// header build at 0x00547A84), and a resend re-stamps the CURRENT
|
||
/// interval id (possibly newer than the fresh-send stamp). ACE never
|
||
/// reads inbound <c>Header.Time</c>, so this is wire-cosmetic against
|
||
/// ACE — but it is retail's behavior.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FreshSend_StampsCurrentIntervalId_ResendRestampsNewer()
|
||
{
|
||
(OutboundFlowQueue queue, VirtualClock virtualClock,
|
||
TransportClock clock, _, List<byte[]> sent) = CreateQueue();
|
||
|
||
// K interval ticks before the send: 2.5 s = 5 intervals, id 1 → 6.
|
||
virtualClock.Advance(TimeSpan.FromSeconds(2.5));
|
||
clock.Update();
|
||
Assert.Equal((ushort)6, clock.IntervalId);
|
||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
|
||
PacketHeader freshHeader = PacketHeader.Unpack(Assert.Single(sent));
|
||
Assert.Equal((ushort)6, freshHeader.Time);
|
||
// N5 fold-in (N4 review F3): the fresh send carries the session
|
||
// iteration, and the resend below keeps it verbatim.
|
||
Assert.Equal(SessionIteration, freshHeader.Iteration);
|
||
|
||
// The interval advances again; the resend carries the CURRENT id,
|
||
// newer than the fresh-send stamp.
|
||
virtualClock.Advance(TimeSpan.FromSeconds(1.0));
|
||
clock.Update();
|
||
Assert.Equal((ushort)8, clock.IntervalId);
|
||
Nak(queue, 2u);
|
||
sent.Clear();
|
||
queue.TransmitPendingResends();
|
||
PacketHeader resentHeader = PacketHeader.Unpack(Assert.Single(sent));
|
||
Assert.Equal((ushort)8, resentHeader.Time);
|
||
Assert.Equal(SessionIteration, resentHeader.Iteration);
|
||
}
|
||
|
||
[Fact]
|
||
public void OnAckSequence_IsWrapSafeMax_AndNeverRegresses()
|
||
{
|
||
(OutboundFlowQueue queue, _, _, _, _) = CreateQueue();
|
||
queue.OnAckSequence(10u);
|
||
Assert.Equal(10u, queue.AckWatermark);
|
||
queue.OnAckSequence(3u); // stale ack must not roll the watermark back
|
||
Assert.Equal(10u, queue.AckWatermark);
|
||
|
||
// Across the wrap: walk the watermark up in half-window-safe steps
|
||
// (like a live sequence stream does), then a small post-wrap value
|
||
// is NEWER than the huge pre-wrap one — and the reverse is stale.
|
||
(OutboundFlowQueue wrapQueue, _, _, _, _) = CreateQueue();
|
||
wrapQueue.OnAckSequence(0x60000000u);
|
||
wrapQueue.OnAckSequence(0xC0000000u);
|
||
wrapQueue.OnAckSequence(0xFFFFFFF6u);
|
||
Assert.Equal(0xFFFFFFF6u, wrapQueue.AckWatermark);
|
||
wrapQueue.OnAckSequence(5u); // newer across the wrap
|
||
Assert.Equal(5u, wrapQueue.AckWatermark);
|
||
wrapQueue.OnAckSequence(0xFFFFFFF6u); // now stale — must not regress
|
||
Assert.Equal(5u, wrapQueue.AckWatermark);
|
||
}
|
||
|
||
// =====================================================================
|
||
// Conformance against the N0 ACE-behaviour double
|
||
// =====================================================================
|
||
|
||
[Fact]
|
||
public void RebuiltResend_VerifiesUnderAceCrypto_WithTheOriginalKey()
|
||
{
|
||
(AceSessionModel model, _) = CreateNegotiatedModel();
|
||
(OutboundFlowQueue queue, _, _, _, List<byte[]> sent) = CreateQueue();
|
||
|
||
// Four reliable packets, seq 2..5; seq 3 is "lost".
|
||
queue.SendGameMessage(MakeMessage(2), GameMessageGroup.UIQueue);
|
||
queue.SendGameMessage(MakeMessage(3), GameMessageGroup.UIQueue);
|
||
queue.SendGameMessage(MakeMessage(4), GameMessageGroup.UIQueue);
|
||
queue.SendGameMessage(MakeMessage(5), GameMessageGroup.UIQueue);
|
||
|
||
model.Receive(sent[0]); // seq 2 in order
|
||
model.Receive(sent[2]); // seq 4: buffered, gap of one — no NAK yet
|
||
model.Receive(sent[3]); // seq 5: desired+2 ≤ arrived → NAK fires
|
||
model.Update();
|
||
byte[] nak = Assert.Single(
|
||
model.TakePendingDatagrams(),
|
||
d => PacketHeader.Unpack(d).Flags
|
||
== PacketHeaderFlags.RequestRetransmit);
|
||
|
||
// Feed the genuine ACE NAK bytes through the same parse the session
|
||
// uses, then sweep: exactly one rebuilt-header resend goes out.
|
||
PacketCodec.PacketDecodeResult decodedNak =
|
||
PacketCodec.TryDecode(nak, inboundIsaac: null);
|
||
Assert.True(decodedNak.IsOk, decodedNak.Error.ToString());
|
||
uint[] ids = decodedNak.Packet!.Optional.RetransmitRequests.ToArray();
|
||
Assert.Equal(new uint[] { 3u }, ids);
|
||
sent.Clear();
|
||
Nak(queue, ids);
|
||
queue.TransmitPendingResends();
|
||
byte[] resent = Assert.Single(sent);
|
||
Assert.Equal(
|
||
PacketHeaderFlags.Retransmission
|
||
| PacketHeaderFlags.EncryptedChecksum
|
||
| PacketHeaderFlags.BlobFragments,
|
||
PacketHeader.Unpack(resent).Flags);
|
||
|
||
// ACE verifies the rebuilt form with the PARKED ORIGINAL key: the
|
||
// 256-key window fully recovers, no orphan, ordering restored.
|
||
model.Receive(resent);
|
||
Assert.Equal(
|
||
new byte[] { 2, 3, 4, 5 },
|
||
model.DispatchedMessages.Select(m => m[0]).ToArray());
|
||
Assert.Equal(5u, model.LastReceivedPacketSequence);
|
||
Assert.Equal(0, model.CrcDropCount);
|
||
Assert.Equal(0, model.DuplicateDropCount);
|
||
Assert.Equal(256, model.Crypto.Headroom);
|
||
Assert.Equal(0, model.Crypto.OrphanCount);
|
||
|
||
// ids[0] = 3 folded as the implicit ack: seq 2 pruned on the sweep,
|
||
// 3..5 still cached.
|
||
Assert.Equal(3, queue.CacheDepth);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The #260 fix end-to-end: a REAL <see cref="WorldSession"/> against
|
||
/// the ACE double, one C2S game-action datagram dropped by the link —
|
||
/// ACE NAKs the gap, the session resends from the cache on its Tick
|
||
/// sweep, and every message dispatches in order with the crypto window
|
||
/// intact.
|
||
/// </summary>
|
||
[Fact]
|
||
public void LostGameAction_IsResentOnNak_AllMessagesDispatchInOrder()
|
||
{
|
||
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));
|
||
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
|
||
int baselineDispatched = transport.Model.DispatchedMessages.Count;
|
||
|
||
// Ten game actions; the link eats C2S datagram #5 of the burst.
|
||
// No Tick runs during the burst, so DropNext deterministically
|
||
// hits the fifth SendTalk datagram.
|
||
var expectedBodies = new List<byte[]>();
|
||
for (int i = 0; i < 10; i++)
|
||
{
|
||
if (i == 4)
|
||
transport.Link.DropNext(LinkDirection.ClientToServer);
|
||
string text = $"msg {i}";
|
||
expectedBodies.Add(ChatRequests.BuildTalk((uint)(i + 1), text));
|
||
session.SendTalk(text);
|
||
}
|
||
|
||
// Pump: the NAK is already queued S2C; Tick consumes it and the
|
||
// end-of-Tick sweep resends the cached datagram.
|
||
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
|
||
while (transport.Model.DispatchedMessages.Count
|
||
< baselineDispatched + 10
|
||
&& DateTime.UtcNow < deadline)
|
||
{
|
||
session.Tick();
|
||
Thread.Sleep(5);
|
||
}
|
||
|
||
// All ten dispatched, byte-identical, in fragment order.
|
||
Assert.Equal(
|
||
expectedBodies,
|
||
transport.Model.DispatchedMessages
|
||
.Skip(baselineDispatched)
|
||
.ToList());
|
||
|
||
// Exactly one resend healed exactly one loss.
|
||
Assert.Equal(1, transport.Link.DroppedCount(LinkDirection.ClientToServer));
|
||
Assert.Equal(1, session.Transport!.Stats.ResendsSent);
|
||
Assert.Equal(1, session.Transport.Stats.NakRequestsReceived);
|
||
Assert.Equal(0, session.Transport.Stats.UncachedNakIds);
|
||
|
||
// The session survived and the crypto window is intact.
|
||
Assert.False(transport.Model.IsTerminated);
|
||
Assert.Equal(0, transport.Model.CrcDropCount);
|
||
Assert.Equal(0, transport.Model.DuplicateDropCount);
|
||
Assert.Equal(256, transport.Model.Crypto.Headroom);
|
||
Assert.Equal(0, transport.Model.Crypto.OrphanCount);
|
||
}
|
||
finally
|
||
{
|
||
session.Dispose();
|
||
}
|
||
|
||
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
|
||
}
|
||
|
||
// =====================================================================
|
||
// Zero-alloc steady state
|
||
// =====================================================================
|
||
|
||
[Fact]
|
||
public void SendGameMessage_SteadyState_AllocatesNothingOnceThePoolWarms()
|
||
{
|
||
var stats = new TransportStats();
|
||
var virtualClock = new VirtualClock();
|
||
var clock = new TransportClock(
|
||
virtualClock.GetTimestamp,
|
||
virtualClock.Frequency);
|
||
var queue = new OutboundFlowQueue(
|
||
MakeIsaac(ClientSeed),
|
||
(ushort)ClientId,
|
||
SessionIteration,
|
||
clock,
|
||
stats,
|
||
static _ => { });
|
||
byte[] body = MakeMessage(0x42);
|
||
|
||
// Warm the pool + queue/dictionary capacity in the same
|
||
// send → ack → sweep rhythm the measurement uses.
|
||
for (int i = 0; i < 128; i++)
|
||
{
|
||
queue.SendGameMessage(body, GameMessageGroup.UIQueue);
|
||
queue.OnAckSequence(queue.HighestIdSent);
|
||
queue.TransmitPendingResends();
|
||
}
|
||
|
||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||
for (int i = 0; i < 1_000; i++)
|
||
{
|
||
queue.SendGameMessage(body, GameMessageGroup.UIQueue);
|
||
queue.OnAckSequence(queue.HighestIdSent);
|
||
queue.TransmitPendingResends();
|
||
}
|
||
|
||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||
Assert.Equal(0, allocated);
|
||
queue.Dispose();
|
||
}
|
||
|
||
// =====================================================================
|
||
// Fixture helpers
|
||
// =====================================================================
|
||
|
||
private (OutboundFlowQueue Queue, VirtualClock VirtualClock,
|
||
TransportClock Clock, TransportStats Stats, List<byte[]> Sent)
|
||
CreateQueue()
|
||
{
|
||
var virtualClock = new VirtualClock();
|
||
var clock = new TransportClock(
|
||
virtualClock.GetTimestamp,
|
||
virtualClock.Frequency);
|
||
var stats = new TransportStats();
|
||
var sent = new List<byte[]>();
|
||
var queue = new OutboundFlowQueue(
|
||
MakeIsaac(ClientSeed),
|
||
(ushort)ClientId,
|
||
SessionIteration,
|
||
clock,
|
||
stats,
|
||
datagram => sent.Add(datagram.ToArray()));
|
||
return (queue, virtualClock, clock, stats, sent);
|
||
}
|
||
|
||
/// <summary>Deliver a NAK id list the way ProcessDatagram does: raw
|
||
/// little-endian u32 ids + count.</summary>
|
||
private static void Nak(OutboundFlowQueue queue, params uint[] ids)
|
||
{
|
||
byte[] bytes = new byte[ids.Length * 4];
|
||
for (int i = 0; i < ids.Length; i++)
|
||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(i * 4), ids[i]);
|
||
queue.OnRetransmitRequest(bytes, ids.Length);
|
||
}
|
||
|
||
/// <summary>An 8-byte message body whose first byte is a test marker.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>A negotiated ACE double, matching the AceSessionModelTests
|
||
/// fixture: LoginRequest → ConnectRequest (discarded) → ConnectResponse
|
||
/// → immediate first TimeSync (discarded; S2C seq 2).</summary>
|
||
private static (AceSessionModel Model, VirtualClock Clock) CreateNegotiatedModel()
|
||
{
|
||
var clock = new VirtualClock();
|
||
var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie);
|
||
model.LoginRequestReceived += model.SendConnectRequest;
|
||
|
||
byte[] login = PacketCodec.Encode(
|
||
new PacketHeader { Flags = PacketHeaderFlags.LoginRequest },
|
||
LoginRequest.Build("testaccount", "testpassword", 1234),
|
||
outboundIsaac: null);
|
||
model.Receive(login);
|
||
model.Update();
|
||
model.TakePendingDatagrams();
|
||
|
||
byte[] cookieBody = new byte[8];
|
||
BinaryPrimitives.WriteUInt64LittleEndian(cookieBody, Cookie);
|
||
byte[] connectResponse = PacketCodec.Encode(
|
||
new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse },
|
||
cookieBody,
|
||
outboundIsaac: null);
|
||
model.Receive(connectResponse);
|
||
model.Update();
|
||
model.TakePendingDatagrams();
|
||
|
||
return (model, clock);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Recover the ISAAC word from an encrypted datagram's checksum:
|
||
/// key = (checksum − headerHash) ^ payloadHash (ClientPacket.cs:142).
|
||
/// </summary>
|
||
private static uint ExtractIsaacKey(byte[] datagram)
|
||
{
|
||
PacketHeader header = PacketHeader.Unpack(datagram);
|
||
ReadOnlySpan<byte> body = datagram.AsSpan(PacketHeader.Size, header.DataSize);
|
||
var optional = new PacketHeaderOptional();
|
||
int consumed = optional.Parse(body, header.Flags);
|
||
Assert.True(consumed >= 0);
|
||
uint payloadHash = optional.CalculateHash32();
|
||
if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
|
||
{
|
||
ReadOnlySpan<byte> remaining = body.Slice(consumed);
|
||
while (!remaining.IsEmpty)
|
||
{
|
||
(MessageFragment? fragment, int fragmentBytes) =
|
||
MessageFragment.TryParse(remaining);
|
||
Assert.NotNull(fragment);
|
||
payloadHash += PacketCodec.CalculateFragmentHash32(fragment!.Value);
|
||
remaining = remaining.Slice(fragmentBytes);
|
||
}
|
||
}
|
||
|
||
return (header.Checksum - header.CalculateHeaderHash32()) ^ payloadHash;
|
||
}
|
||
|
||
/// <summary>A cache entry whose buffer is rented from
|
||
/// <paramref name="pool"/>, so rent/return balance is assertable.</summary>
|
||
private static SentPacketStore.CachedPacket RentedEntry(
|
||
CountingPool pool,
|
||
uint sequence)
|
||
{
|
||
byte[] buffer = pool.Rent(PacketHeader.Size + 24);
|
||
return new SentPacketStore.CachedPacket(
|
||
sequence,
|
||
buffer,
|
||
bodyLength: 24,
|
||
sealedChecksum: 0u,
|
||
isaacKey: 0u,
|
||
hasFragments: true);
|
||
}
|
||
|
||
/// <summary>ArrayPool wrapper counting rents/returns for balance
|
||
/// assertions.</summary>
|
||
private sealed class CountingPool : ArrayPool<byte>
|
||
{
|
||
public int Rented { get; private set; }
|
||
public int Returned { get; private set; }
|
||
|
||
public override byte[] Rent(int minimumLength)
|
||
{
|
||
Rented++;
|
||
return Shared.Rent(minimumLength);
|
||
}
|
||
|
||
public override void Return(byte[] array, bool clearArray = false)
|
||
{
|
||
Returned++;
|
||
Shared.Return(array, clearArray);
|
||
}
|
||
}
|
||
}
|