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