using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net.Tests.Transport;
///
/// Campaign N Slice N6 — the ConnectResponse handshake retransmit.
///
///
/// Retail: while a connection sits in cs_ConnectionRequestAcked,
/// ClientNet::ProcessConnection @ 0x00545450 (case 0 at 0x0054547B)
/// re-sends the ConnectResponse every 0.333333333 s — strictly-greater gate
/// on lastSentHandshake_ (mask-0x41 x87 test at 0x0054548C) —
/// through ClientNet::SendConnectAck @ 0x005440F0, which re-stamps
/// the clock (0x00544102) and rebuilds the same cookie packet. The resend
/// stops when the first successfully processed non-ConnectRequest packet
/// promotes the connection to cs_Connected
/// (ClientNet::ProcessPacket @ 0x00545100, the state set at
/// 0x00545160).
///
///
///
/// Clocking: the retry cadence rides the TransportClock, so these tests
/// drive it on VIRTUAL time (TransportClockSource +
/// AutoAdvanceOnBlockingReceive); only the Connect deadline is
/// wall-clock. Before N6 the first test hung to that deadline — a lost
/// ConnectResponse was an unconditional Connect failure, and the N5 loss
/// decorator deliberately arms AFTER the handshake window, so nothing
/// covered it.
///
///
public sealed class ConnectResponseRetransmitTests
{
/// THE N6 conformance test: the first ConnectResponse datagram
/// dies on the wire; the 0.333 s retry lands; the session completes.
/// Exactly one retry — confirmation (the first decoded server packet)
/// stops the cadence.
[Fact]
public void Connect_FirstConnectResponseDropped_RetryHealsHandshake()
{
var fake = new FakeAceTransport
{
AutoAdvanceOnBlockingReceive = TimeSpan.FromMilliseconds(200),
};
int connectResponsesSent = 0;
fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) =>
{
if (!IsConnectResponse(datagram))
return false;
connectResponsesSent++;
return connectResponsesSent == 1; // only the FIRST one dies
});
int accepted = 0;
fake.Model.ConnectResponseAccepted += () => accepted++;
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
fake);
session.TransportClockSource =
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState);
Assert.NotNull(session.Characters);
// Original + exactly one retry, and none after confirmation (the
// CharacterList response follows the accepted retry immediately).
Assert.Equal(2, connectResponsesSent);
Assert.Equal(1, accepted);
// The retry arrived while ACE was still AuthConnectResponse — the
// NetworkManager pre-route accepted it; nothing was state-dropped
// and no keystream/CRC cost was paid anywhere.
Assert.Equal(0, fake.Model.StateDropCount);
Assert.Equal(0, fake.Model.CrcDropCount);
Assert.Equal(0, fake.Model.DuplicateDropCount);
Assert.Equal(256, fake.Model.Crypto.Headroom);
}
/// A clean handshake sends exactly ONE ConnectResponse: with
/// the virtual clock frozen (no auto-advance) the strict 0.333 s gate
/// can never open, and confirmation lands on the first pump.
[Fact]
public void Connect_CleanHandshake_SendsExactlyOneConnectResponse()
{
var fake = new FakeAceTransport();
int connectResponsesSent = 0;
fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) =>
{
if (IsConnectResponse(datagram))
connectResponsesSent++;
return false; // tap only
});
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
fake);
session.TransportClockSource =
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
Assert.NotNull(session.Characters);
Assert.Equal(1, connectResponsesSent);
Assert.Equal(0, fake.Model.StateDropCount);
}
///
/// The duplicate-in-flight scenario: ACE accepted the ConnectResponse
/// but its S2C responses died, so the unconfirmed client keeps
/// retrying. Every duplicate lands harmlessly (ACE's
/// Session.CheckState clause 2 drops it pre-CRC at zero
/// keystream cost), and the dropped responses heal through the normal
/// N2/N4 gap-walk → NAK → cached-retransmit path once ACE's 2 s ack
/// reveals the gap.
///
[Fact]
public void Connect_ServerResponsesLost_RetriesDropHarmlessly_SessionHeals()
{
var fake = new FakeAceTransport
{
AutoAdvanceOnBlockingReceive = TimeSpan.FromMilliseconds(200),
};
// S2C transmit index 0 is the ConnectRequest; indices 1 and 2 are
// the TimeSync + CharacterList responses to the accepted
// ConnectResponse. Drop both responses.
fake.Link.DropAt(LinkDirection.ServerToClient, 1);
fake.Link.DropAt(LinkDirection.ServerToClient, 2);
int connectResponsesSent = 0;
fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) =>
{
if (IsConnectResponse(datagram))
connectResponsesSent++;
return false; // tap only
});
int accepted = 0;
fake.Model.ConnectResponseAccepted += () => accepted++;
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
fake);
session.TransportClockSource =
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
Assert.NotNull(session.Characters);
// The first ConnectResponse was accepted; at least one retry went
// out while the client sat unconfirmed, and every one of them was
// state-dropped by the model without side effects.
Assert.Equal(1, accepted);
Assert.True(connectResponsesSent >= 2,
$"expected retries, saw {connectResponsesSent}");
Assert.True(fake.Model.StateDropCount >= 1,
$"expected CheckState drops, saw {fake.Model.StateDropCount}");
Assert.Equal(0, fake.Model.CrcDropCount);
Assert.Equal(0, fake.Model.DuplicateDropCount);
// The dropped TimeSync/CharacterList healed via the client NAK →
// ACE cached-retransmit path, with the parked-key discipline intact.
Assert.True(session.Transport!.Stats.NaksSent >= 1);
Assert.True(session.Transport.Stats.KeysParked >= 2);
Assert.Equal(0, session.Transport.Stats.ChecksumFailures);
Assert.Equal(256, fake.Model.Crypto.Headroom);
}
/// Model-level pin of the ACE-safety claim: a duplicate
/// ConnectResponse AFTER acceptance is dropped by CheckState clause 2
/// (Session.cs:98-99 / NetworkManager.cs:60-66) before CRC — harmless,
/// stateless, zero keystream cost.
[Fact]
public void AceModel_DuplicateConnectResponse_AfterAcceptance_DropsViaCheckState()
{
var clock = new VirtualClock();
var model = new AceSessionModel(
clock,
FakeAceTransport.DefaultClientSeed,
FakeAceTransport.DefaultServerSeed,
FakeAceTransport.DefaultClientId,
FakeAceTransport.DefaultCookie);
int accepted = 0;
model.ConnectResponseAccepted += () => accepted++;
byte[] cookieBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(
cookieBody, FakeAceTransport.DefaultCookie);
byte[] connectResponse = PacketCodec.Encode(
new PacketHeader
{
Sequence = 1,
Flags = PacketHeaderFlags.ConnectResponse,
Id = 0,
},
cookieBody,
null);
// AuthenticationHandler moves the session to AuthConnectResponse
// when the ConnectRequest goes out.
model.SendConnectRequest();
Assert.Equal(AceSessionState.AuthConnectResponse, model.State);
model.Receive(connectResponse);
Assert.Equal(AceSessionState.AuthConnected, model.State);
Assert.Equal(1, accepted);
Assert.Equal(0, model.StateDropCount);
// The duplicate: CheckState clause 2 drops it pre-CRC.
model.Receive(connectResponse);
Assert.Equal(AceSessionState.AuthConnected, model.State);
Assert.Equal(1, accepted);
Assert.Equal(1, model.StateDropCount);
Assert.Equal(0, model.CrcDropCount);
Assert.Equal(0, model.DuplicateDropCount);
Assert.Equal(256, model.Crypto.Headroom);
}
private static bool IsConnectResponse(byte[] datagram) =>
datagram.Length >= PacketHeader.Size
&& (BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(4))
& (uint)PacketHeaderFlags.ConnectResponse) != 0;
}
///
/// Campaign N Slice N6 — the transport end of the fragment-assembler
/// eviction: runs
/// on retail's 5 s flush
/// cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0).
///
public sealed class ReliableTransportAssemblerSweepTests
{
[Fact]
public void Sweep_EvictsAgedPartial_KeepsFreshOne()
{
var clock = new VirtualClock();
var assembler = new FragmentAssembler(() => clock.Seconds);
var transport = new ReliableTransport(
MakeIsaac(0x11AA22BBu),
MakeIsaac(0x33CC44DDu),
0x1234,
1,
_ => { },
new TransportClock(clock.GetTimestamp, clock.Frequency),
assembler: assembler);
// Park a partial at t=0.
IngestPartial(assembler, sequence: 10);
Assert.Equal(1, assembler.PartialCount);
// Well under the TTL: sweeps run (5 s cadence) but evict nothing.
clock.Advance(TimeSpan.FromSeconds(30));
transport.Sweep();
Assert.Equal(1, assembler.PartialCount);
// Park a second partial at t=58, then cross the first one's TTL.
clock.Advance(TimeSpan.FromSeconds(28));
IngestPartial(assembler, sequence: 11);
clock.Advance(TimeSpan.FromSeconds(3.5)); // t = 61.5
transport.Sweep();
Assert.Equal(1, assembler.PartialCount); // 10 evicted, 11 kept
transport.Dispose();
}
private static void IngestPartial(FragmentAssembler assembler, uint sequence)
{
var header = new MessageFragmentHeader
{
Sequence = sequence,
Id = 0x80000000u,
Count = 2,
Index = 0,
TotalSize = (ushort)(MessageFragmentHeader.Size + 1),
Queue = 7,
};
byte[] payload = { 0x42 };
Assert.False(assembler.TryIngest(
new BorrowedMessageFragment(header, payload),
out _,
out _));
}
private static IsaacRandom MakeIsaac(uint seed)
{
Span seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
return new IsaacRandom(seedBytes);
}
}