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 N3 — the AckNakScheduler: retail's 2.0 s cumulative /// AckSequence (SharedNet::EnqueuePak @ 0x00543B10, the only /// 0x4000 construction site in the binary) replacing the Phase 4.9 /// per-packet reflex ack, arbitrated NAK-xor-ack on ONE shared timestamp /// (ClientNet::ProcessConnection @ 0x00545450, /// ReceiverData::timeStamp_ — campaign landmine #7). /// public sealed class AckNakSchedulerTests { 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; // ===================================================================== // The 2.0 s gate — SharedNet::EnqueuePak @ 0x00543B10 // ===================================================================== [Fact] public void CumulativeAck_TwoSecondGate_CarriesWatermarkAtEmission() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); Admit(transport, 2u); Admit(transport, 3u); // No ack before 2.0 s — the gate armed at transport construction // (ReceiverData::SharedInit @ 0x00548EF0, reached from // ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time). transport.Sweep(); clock.Advance(TimeSpan.FromSeconds(1.99)); transport.Sweep(); Assert.Empty(sent); // Exactly one at the boundary (the retail compare is >=, the x87 // `& 1` status test at 0x00543B3D). Header.Time carries the // interval id at emission (N4 control-header rule): 2.0 s of // 0.5 s intervals on top of the initial id 1 → 5. clock.Advance(TimeSpan.FromSeconds(0.01)); transport.Sweep(); Assert.Equal((ushort)5, transport.Clock.IntervalId); AssertAckShape( Assert.Single(sent), expectedSequence: 1u, expectedValue: 3u, expectedTime: transport.Clock.IntervalId); Assert.Equal(1, transport.Stats.AcksSent); // The gate reset: silent until the next 2.0 s elapses. transport.Sweep(); clock.Advance(TimeSpan.FromSeconds(1.99)); transport.Sweep(); Assert.Single(sent); // The watermark advanced between gates: the NEWER value rides — // the ack always carries highestIDReceived_ AT EMISSION. Admit(transport, 4u); Admit(transport, 5u); clock.Advance(TimeSpan.FromSeconds(0.01)); transport.Sweep(); Assert.Equal(2, sent.Count); AssertAckShape( sent[1], expectedSequence: 1u, expectedValue: 5u, expectedTime: transport.Clock.IntervalId); Assert.Equal(2, transport.Stats.AcksSent); } // ===================================================================== // Flags equality (landmine #5) — model-side acceptance // ===================================================================== /// /// ACE's dedup exemption (NetworkSession.cs:342-343) and watermark-skip /// (:474-476) both require Flags == AckSequence EXACTLY. The /// emitted ack must be accepted at the reused client sequence WITHOUT /// advancing ACE's watermark — any extra ORed bit would advance the /// watermark past a live sequence and wedge the session (§3 row 3). /// [Fact] public void ModelAcceptsAck_AtReusedSequence_WithoutAdvancingWatermark() { (AceSessionModel model, _) = CreateNegotiatedModel(); (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); // Two reliable client packets reach ACE: sequences 2, 3. transport.Outbound.SendGameMessage( MakeMessage(1), GameMessageGroup.UIQueue); transport.Outbound.SendGameMessage( MakeMessage(2), GameMessageGroup.UIQueue); foreach (byte[] datagram in sent) model.Receive(datagram); Assert.Equal(3u, model.LastReceivedPacketSequence); sent.Clear(); // The sweep's cumulative ack borrows sequence 3 — the last issued // client sequence, not a fresh one. clock.Advance(TimeSpan.FromSeconds(2)); transport.Sweep(); byte[] ack = Assert.Single(sent); PacketHeader ackHeader = PacketHeader.Unpack(ack); Assert.Equal(3u, ackHeader.Sequence); Assert.Equal( (uint)PacketHeaderFlags.AckSequence, (uint)ackHeader.Flags); model.Receive(ack); Assert.Equal(0, model.DuplicateDropCount); Assert.Equal(0, model.CrcDropCount); Assert.Equal(0, model.StateDropCount); // The watermark did NOT advance (:474-476 — exact-flags skip). Assert.Equal(3u, model.LastReceivedPacketSequence); } // ===================================================================== // NAK-xor-ack mutual exclusivity (landmine #7) — // ClientNet::ProcessConnection @ 0x00545450 // ===================================================================== [Fact] public void ParkedNak_SuppressesTheAck_AckResumesWhenTheGapClears() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); Admit(transport, 2u); // watermark 2, no gap Admit(transport, 4u); // gap walk parks id 3 Assert.Equal(1, transport.Inbound.NakCount); // 2.5 s elapses with the NAK set non-empty: the NAK branch owns the // sweep — ONE RequestRetransmit goes out and never an ack while ids // are parked (§2.3's mutual exclusivity; the N2 ledger row shows why // acking here would let ACE prune the lost id from its S2C cache // before the NAK). The immediate second sweep is silenced by the // freshly stamped shared timestamp. clock.Advance(TimeSpan.FromSeconds(2.5)); transport.Sweep(); transport.Sweep(); byte[] nak = Assert.Single(sent); Assert.Equal( (uint)PacketHeaderFlags.RequestRetransmit, (uint)PacketHeader.Unpack(nak).Flags); Assert.Equal(0, transport.Stats.AcksSent); Assert.Equal(1, transport.Stats.NaksSent); sent.Clear(); // The missing packet arrives (late delivery), clearing the set — // the ack resumes once 2.0 s elapse past the NAK's stamp of the // SHARED timestamp (landmine #7: a NAK delays the next ack). Admit(transport, 3u); Assert.Equal(0, transport.Inbound.NakCount); transport.Sweep(); Assert.Empty(sent); clock.Advance(TimeSpan.FromSeconds(2.0)); transport.Sweep(); AssertAckShape( Assert.Single(sent), expectedSequence: 1u, expectedValue: 4u, expectedTime: transport.Clock.IntervalId); } // ===================================================================== // Ack-storm collapse — retail never acks per packet // ===================================================================== [Fact] public void CreateObjectFlood_InsideOneWindow_CollapsesToOneAck() { (ReliableTransport transport, VirtualClock clock, List sent) = CreateTransport(); // A simulated CreateObject flood: 50 sequenced arrivals across // 1.0 s, the per-frame sweep interleaved. The pre-N3 reflex ack // sent 50 acks — one per packet; retail sends NONE until the gate. uint sequence = 2; for (int i = 0; i < 50; i++) { Admit(transport, sequence++); clock.Advance(TimeSpan.FromMilliseconds(20)); transport.Sweep(); } Assert.Empty(sent); // Crossing the 2.0 s gate: exactly ONE cumulative ack for the // whole flood, carrying the final watermark. clock.Advance(TimeSpan.FromSeconds(1.0)); transport.Sweep(); AssertAckShape( Assert.Single(sent), expectedSequence: 1u, expectedValue: 51u, expectedTime: transport.Clock.IntervalId); Assert.Equal(1, transport.Stats.AcksSent); } // ===================================================================== // Conformance against the N0 ACE-behaviour double (real WorldSession) // ===================================================================== /// /// The keepalive property the reflex ack used to provide, now proven /// for the cumulative ack: a QUIET session (no game actions; the only /// inbound is ACE's own TimeSync every 20 s and ack every 2 s) still /// sends one cleartext ack per ~2 s, and each one refreshes ACE's 60 s /// TimeoutDeadline (NetworkSession.cs:329-331) — the session survives /// far past the 60 s horizon. /// [Fact] public void QuietSession_CumulativeAcksKeepAceAlive_PastThe60sHorizon() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); session.TransportClockSource = (transport.Clock.GetTimestamp, transport.Clock.Frequency); try { session.Connect( "testaccount", "testpassword", TimeSpan.FromSeconds(10)); session.EnterWorld(0, TimeSpan.FromSeconds(10)); Assert.Equal(WorldSession.State.InWorld, session.CurrentState); // 120 virtual seconds in 0.5 s frames — double ACE's timeout // horizon. The model's Update checks its deadline every pump. for (int frame = 0; frame < 240; frame++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); transport.PumpServer(); session.Tick(); Thread.Sleep(1); } Assert.False(transport.Model.IsTerminated); Assert.Equal( AceTerminationReason.None, transport.Model.TerminationReason); // The deadline is FRESH — refreshed within the last ack // interval, not merely unexpired. long margin = transport.Model.TimeoutDeadlineTimestamp - transport.Clock.GetTimestamp(); Assert.True( margin > TimeSpan.FromSeconds(50).Ticks, $"TimeoutDeadline margin {margin} ticks — the acks are not refreshing it"); // ~60 acks expected over 120 s; ≥ 40 pins the ~2 s cadence // without depending on frame phase. long acksSent = session.Transport!.Stats.AcksSent; Assert.True( acksSent >= 40, $"only {acksSent} cumulative acks over 120 virtual seconds"); Assert.Equal(0, transport.Model.CrcDropCount); Assert.Equal(0, transport.Model.StateDropCount); Assert.Equal(0, transport.Model.DuplicateDropCount); Assert.Equal(0, session.Transport.Inbound.NakCount); } finally { session.Dispose(); } Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); } /// /// Full lifecycle against the double: clean run completes, an in-world /// S2C flood collapses to one ack at the next gate, the model timeout /// never fires, and teardown is OUR graceful Disconnect — with zero /// CRC/state/duplicate drops end to end. /// [Fact] public void FullLifecycle_CleanRun_FloodCollapses_GracefulTeardown() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); session.TransportClockSource = (transport.Clock.GetTimestamp, transport.Clock.Frequency); try { session.Connect( "testaccount", "testpassword", TimeSpan.FromSeconds(10)); session.EnterWorld(0, TimeSpan.FromSeconds(10)); Assert.Equal(WorldSession.State.InWorld, session.CurrentState); var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); // An S2C flood inside one 2 s window: 20 messages, each pumped // into its own sequenced packet (the pre-N3 reflex ack answered // every one of them). long acksBefore = session.Transport!.Stats.AcksSent; for (int i = 0; i < 20; i++) { transport.Model.EnqueueGameMessage( BuildServerMessage($"flood {i}"), GameMessageGroup.UIQueue); transport.PumpServer(); } PumpUntil(session, () => messages.Count >= 20); Assert.Equal(acksBefore, session.Transport.Stats.AcksSent); // Exactly ONE cumulative ack at the next gate covers the // whole flood. transport.Clock.Advance(TimeSpan.FromSeconds(2)); session.Tick(); Assert.Equal( acksBefore + 1, session.Transport.Stats.AcksSent); // A few more quiet gates keep flowing. for (int frame = 0; frame < 10; frame++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); transport.PumpServer(); session.Tick(); Thread.Sleep(1); } Assert.False(transport.Model.IsTerminated); Assert.Equal(0, transport.Model.CrcDropCount); Assert.Equal(0, transport.Model.StateDropCount); Assert.Equal(0, transport.Model.DuplicateDropCount); Assert.Equal(256, transport.Model.Crypto.Headroom); Assert.Equal(0, session.Transport.Inbound.NakCount); } finally { session.Dispose(); } // The model terminated on OUR transport Disconnect — the 60 s // timeout never fired. Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); Assert.True(transport.Model.IsTerminated); Assert.Equal( AceTerminationReason.PacketHeaderDisconnect, transport.Model.TerminationReason); } // ===================================================================== // Fixture helpers // ===================================================================== /// A full transport on a virtual clock; every emitted datagram /// (game sends AND scheduler acks) lands in Sent. private static (ReliableTransport Transport, VirtualClock Clock, List Sent) CreateTransport() { var virtualClock = new VirtualClock(); var sent = new List(); var transport = new ReliableTransport( MakeIsaac(ClientSeed), MakeIsaac(ServerSeed), (ushort)ClientId, SessionIteration, datagram => sent.Add(datagram.ToArray()), new TransportClock( virtualClock.GetTimestamp, virtualClock.Frequency)); return (transport, virtualClock, sent); } /// Admit one encrypted sequenced arrival that must not drop. private static void Admit(ReliableTransport transport, uint sequence) { InboundSequenceTracker.Admission admission = transport.Inbound.Admit(sequence, encrypted: true); Assert.False(admission.Drop); } /// /// Pin the full 24-byte wire shape of the cumulative ack (campaign /// §2.3): flags are an EQUALITY match on AckSequence — the raw /// uint compare fails if ANY extra bit is ORed in (landmine #5) — /// cleartext (decodes with a null keystream), 4-byte little-endian /// body carrying the watermark, borrowed header sequence, session /// client id, and the N4 control-header rule: Time = the /// interval id at emission and Iteration = the session /// iteration (retail's shared header build at 0x00547A84). /// private static void AssertAckShape( byte[] datagram, uint expectedSequence, uint expectedValue, ushort expectedTime) { Assert.Equal(PacketHeader.Size + sizeof(uint), datagram.Length); PacketHeader header = PacketHeader.Unpack(datagram); Assert.Equal( (uint)PacketHeaderFlags.AckSequence, (uint)header.Flags); Assert.Equal(expectedSequence, header.Sequence); Assert.Equal((ushort)ClientId, header.Id); Assert.Equal(expectedTime, header.Time); Assert.Equal(SessionIteration, header.Iteration); Assert.Equal((ushort)sizeof(uint), header.DataSize); Assert.Equal( expectedValue, BinaryPrimitives.ReadUInt32LittleEndian( datagram.AsSpan(PacketHeader.Size))); // Cleartext: verifies additively with no ISAAC word. PacketCodec.PacketDecodeResult decoded = PacketCodec.TryDecode(datagram, inboundIsaac: null); Assert.True(decoded.IsOk, decoded.Error.ToString()); Assert.Equal(expectedValue, decoded.Packet!.Optional.AckSequence); } /// 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 /// OutboundReliableTransportTests 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); } private static void PumpUntil(WorldSession session, Func condition) { DateTime deadline = DateTime.UtcNow.AddSeconds(10); while (!condition() && DateTime.UtcNow < deadline) { session.Tick(); Thread.Sleep(5); } Assert.True(condition(), "condition not reached before the deadline"); } private static byte[] BuildServerMessage(string text) { var writer = new PacketWriter(64 + text.Length); writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 writer.WriteString16L(text); writer.WriteUInt32(1); // ChatMessageType return writer.ToArray(); } }