Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.
New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
`intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
@ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
@ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
@ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
store, the wrap-safe sorted dedup pending-resend list
(FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
Cache commit happens AFTER a successful send
(FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
NAK ids[0] folds into the watermark as retail's implicit cumulative ack
(RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
with fragments), Time = current interval id, Sequence/Id/Iteration/
DataSize verbatim, checksum = fresh header hash + stored sealed checksum
(FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
original ISAAC key rides inside the sealed value - no new keystream word
is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
this slice.
- TransportStats: unconditional counters (ResendsSent,
NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.
PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.
WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.
Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.
N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.
Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.
Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
660 lines
26 KiB
C#
660 lines
26 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 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);
|
||
Assert.Equal((ushort)0, originalHeader.Time);
|
||
|
||
// 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);
|
||
}
|
||
|
||
[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,
|
||
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,
|
||
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);
|
||
}
|
||
}
|
||
}
|