feat(net): N1 - outbound sent-packet cache + resend on NAK
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>
This commit is contained in:
parent
e395861053
commit
43e60a6971
13 changed files with 1491 additions and 65 deletions
|
|
@ -556,21 +556,32 @@ internal sealed class AceSessionModel
|
|||
|
||||
if (fragment.Header.Count != 1)
|
||||
{
|
||||
// :489-518 — split message, buffered by fragment sequence.
|
||||
if (!_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer))
|
||||
// ACE's two literal branches, kept separate because only ONE of
|
||||
// them checks Complete:
|
||||
if (_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer))
|
||||
{
|
||||
buffer = new PartialC2SMessage(fragment.Header.Count);
|
||||
_partialFragments.Add(fragment.Header.Sequence, buffer);
|
||||
// :495-507 — existing buffer: add, then check Complete.
|
||||
buffer.AddFragment(fragment.Header.Index, fragment.Payload);
|
||||
if (buffer.Complete)
|
||||
{
|
||||
// :504-506 — TryGetMessage may return null (assembled
|
||||
// stream under 4 bytes, MessageBuffer.cs:49-50) but the
|
||||
// buffer is removed EITHER WAY.
|
||||
message = buffer.TryGetMessage();
|
||||
_partialFragments.Remove(fragment.Header.Sequence);
|
||||
}
|
||||
}
|
||||
|
||||
buffer.AddFragment(fragment.Header.Index, fragment.Payload);
|
||||
if (buffer.Complete)
|
||||
else
|
||||
{
|
||||
// :504-506 — TryGetMessage may return null (assembled stream
|
||||
// under 4 bytes, MessageBuffer.cs:49-50) but the buffer is
|
||||
// removed EITHER WAY.
|
||||
message = buffer.TryGetMessage();
|
||||
_partialFragments.Remove(fragment.Header.Sequence);
|
||||
// :509-518 — new buffer: construct + AddFragment + TryAdd,
|
||||
// WITHOUT checking Complete. A fragment whose Count can
|
||||
// never be reached from here (Count == 0: AddFragment
|
||||
// refuses to add to an already-"Complete" buffer) parks a
|
||||
// dead buffer in partialFragments forever — ACE
|
||||
// bug-for-bug.
|
||||
var newBuffer = new PartialC2SMessage(fragment.Header.Count);
|
||||
newBuffer.AddFragment(fragment.Header.Index, fragment.Payload);
|
||||
_partialFragments.TryAdd(fragment.Header.Sequence, newBuffer);
|
||||
}
|
||||
}
|
||||
else if (fragment.Payload.Length >= 4)
|
||||
|
|
|
|||
|
|
@ -453,14 +453,16 @@ public sealed class AceSessionModelTests
|
|||
|
||||
// ...while ACE's ClientPacketFragment.Unpack (:10-23) only checks
|
||||
// 16 ≤ Size ≤ 464, so the packet is parsed, CRC-verified and
|
||||
// processed. ProcessFragment takes the split branch (Count != 1), the
|
||||
// buffer is Complete at zero fragments, TryGetMessage returns null,
|
||||
// and the whole thing evaporates — the packet still burns its
|
||||
// keystream word and still advances the watermark.
|
||||
// processed. ProcessFragment takes the split branch (Count != 1)
|
||||
// and its NEW-buffer arm (:509-518), which never checks Complete:
|
||||
// MessageBuffer.AddFragment refuses to add to the already-
|
||||
// "Complete" zero-count buffer, no message ever dispatches, and the
|
||||
// dead buffer stays PARKED in partialFragments forever. The packet
|
||||
// still burns its keystream word and still advances the watermark.
|
||||
model.Receive(zeroCount);
|
||||
Assert.Equal(0, model.CrcDropCount);
|
||||
Assert.Empty(model.DispatchedMessages);
|
||||
Assert.Equal(0, model.PartialFragmentBufferCount);
|
||||
Assert.Equal(1, model.PartialFragmentBufferCount);
|
||||
Assert.Equal(0u, model.LastReceivedFragmentSequence);
|
||||
Assert.Equal(2u, model.LastReceivedPacketSequence);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue