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;
///
/// 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 with the ORIGINAL
/// keystream word — campaign landmines #1/#2).
///
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(
() => 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 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 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 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 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);
}
///
/// N3 fold-in of the N1 review advisory: retail stamps
/// CurLocalInterval_.intervalID_ into Header.Time on every
/// FRESH packet (FlowQueue::TransmitNewPackets @ 0x00547A60, 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 Header.Time, so this is wire-cosmetic against
/// ACE — but it is retail's behavior.
///
[Fact]
public void FreshSend_StampsCurrentIntervalId_ResendRestampsNewer()
{
(OutboundFlowQueue queue, VirtualClock virtualClock,
TransportClock clock, _, List 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 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);
}
///
/// The #260 fix end-to-end: a REAL 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.
///
[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();
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 Sent)
CreateQueue()
{
var virtualClock = new VirtualClock();
var clock = new TransportClock(
virtualClock.GetTimestamp,
virtualClock.Frequency);
var stats = new TransportStats();
var sent = new List();
var queue = new OutboundFlowQueue(
MakeIsaac(ClientSeed),
(ushort)ClientId,
SessionIteration,
clock,
stats,
datagram => sent.Add(datagram.ToArray()));
return (queue, virtualClock, clock, stats, sent);
}
/// Deliver a NAK id list the way ProcessDatagram does: raw
/// little-endian u32 ids + count.
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);
}
/// An 8-byte message body whose first byte is a test marker.
private static byte[] MakeMessage(byte marker) =>
new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 };
private static IsaacRandom MakeIsaac(uint seed)
{
Span seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
return new IsaacRandom(seedBytes);
}
/// A negotiated ACE double, matching the AceSessionModelTests
/// fixture: LoginRequest → ConnectRequest (discarded) → ConnectResponse
/// → immediate first TimeSync (discarded; S2C seq 2).
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);
}
///
/// Recover the ISAAC word from an encrypted datagram's checksum:
/// key = (checksum − headerHash) ^ payloadHash (ClientPacket.cs:142).
///
private static uint ExtractIsaacKey(byte[] datagram)
{
PacketHeader header = PacketHeader.Unpack(datagram);
ReadOnlySpan 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 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;
}
/// A cache entry whose buffer is rented from
/// , so rent/return balance is assertable.
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);
}
/// ArrayPool wrapper counting rents/returns for balance
/// assertions.
private sealed class CountingPool : ArrayPool
{
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);
}
}
}