acdream/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs
Erik 4e290f00d8 feat(net): N5 - loss observability, lossy decorator, the connected loss gate
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>
2026-07-29 16:26:06 +02:00

708 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}
}