using System.Buffers.Binary; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Tests.Transport; /// /// Tests OF the ACE-behaviour double — they pin the model against the ACE /// source rules cited inside so slices N1-N5 /// can trust it as the referee. They do not test acdream production code. /// public sealed class AceSessionModelTests { private const uint ClientSeed = 0x11AA22BBu; private const uint ServerSeed = 0x33CC44DDu; private const uint ClientId = 0x1234u; private const ulong Cookie = 0xFEEDFACECAFEBABEUL; // ===================================================================== // Session.CheckState — the pre-CRC inbound gate (Session.cs:93-113) // ===================================================================== [Fact] public void CheckState_DropsControlPacketsBeforeNegotiation_ThenConsumesThemAfter() { var clock = new VirtualClock(); var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie); model.LoginRequestReceived += model.SendConnectRequest; var client = new TestAcClient(ClientSeed); Assert.Equal(AceSessionState.AuthLoginRequest, model.State); // Built now, while the client's outbound wheel is at word 1 — so the // SAME bytes must still verify after the handshake if (and only if) // the pre-handshake delivery really cost no keystream. byte[] cleartextAck = client.BuildCleartextAck(headerSequence: 2, ackValue: 1); byte[] encryptedAck = client.BuildEncryptedAck(headerSequence: 2, ackValue: 1); uint keyBeforeGate = model.Crypto.CurrentKey; // Session.cs:101-102 — ANY of AckSequence|TimeSync|EchoRequest|Flow // while State == AuthLoginRequest is dropped by Session.ProcessPacket // BEFORE NetworkSession.ProcessPacket runs, so it never reaches // ClientPacket.VerifyCRC: no keystream word, no watermark move, no // CRC counter. model.Receive(cleartextAck); model.Receive(encryptedAck); Assert.Equal(2, model.StateDropCount); Assert.Equal(0, model.CrcDropCount); Assert.Equal(0, model.DuplicateDropCount); Assert.Equal(1u, model.LastReceivedPacketSequence); Assert.Equal(keyBeforeGate, model.Crypto.CurrentKey); Assert.Equal(256, model.Crypto.Headroom); Assert.Equal(0, model.Crypto.OrphanCount); // Negotiate: LoginRequest → ConnectRequest (AuthenticationHandler.cs:127, // :232) → ConnectResponse (NetworkManager.cs:77). model.Receive(BuildLoginRequest()); Assert.Equal(AceSessionState.AuthConnectResponse, model.State); model.Update(); model.TakePendingDatagrams(); model.Receive(BuildConnectResponse()); Assert.Equal(AceSessionState.AuthConnected, model.State); model.Update(); model.TakePendingDatagrams(); // The same cleartext ack now passes the gate. Flags are EXACTLY // AckSequence so the watermark stays put (:474-476) and no key is // involved. model.Receive(cleartextAck); Assert.Equal(2, model.StateDropCount); Assert.Equal(0, model.CrcDropCount); Assert.Equal(1u, model.LastReceivedPacketSequence); Assert.Equal(keyBeforeGate, model.Crypto.CurrentKey); // And the encrypted one is consumed normally: its ORIGINAL key (drawn // before the handshake) is still the server's current key, proving the // gate cost nothing. Its flags are not exactly AckSequence, so the // watermark does advance. model.Receive(encryptedAck); Assert.Equal(0, model.CrcDropCount); Assert.Equal(2u, model.LastReceivedPacketSequence); Assert.NotEqual(keyBeforeGate, model.Crypto.CurrentKey); Assert.Equal(256, model.Crypto.Headroom); } [Fact] public void CheckState_DropsLoginRequestAndConnectResponseOutOfState() { (AceSessionModel model, _, _) = CreateNegotiatedModel(); Assert.Equal(AceSessionState.AuthConnected, model.State); int loginRequests = 0; int connectResponses = 0; model.LoginRequestReceived += () => loginRequests++; model.ConnectResponseAccepted += () => connectResponses++; // Session.cs:95-96 — a LoginRequest after the handshake is dropped // before the auth handler ever sees it. model.Receive(BuildLoginRequest()); Assert.Equal(1, model.StateDropCount); Assert.Equal(0, loginRequests); // Session.cs:98-99 (and NetworkManager.cs:60-66, whose session lookup // requires State == AuthConnectResponse) — a replayed ConnectResponse // cannot re-run the handshake. model.Receive(BuildConnectResponse()); Assert.Equal(2, model.StateDropCount); Assert.Equal(0, connectResponses); Assert.Equal(0, model.CrcDropCount); Assert.Equal(0, model.DuplicateDropCount); Assert.Equal(AceSessionState.AuthConnected, model.State); } // ===================================================================== // Inbound sequencing / crypto discipline // ===================================================================== [Fact] public void Nak_FiresOnlyAtDesiredPlusTwo_WithOneSecondRateLimit() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); byte[][] packets = BuildSequentialPackets(client, count: 7); // seq 2..8 // Gap of one: desired = 2, arrived = 3 → desired+2 (4) > 3 → buffered, NO NAK // (NetworkSession.cs:351-363 — ACE needs two arrivals past the gap). model.Receive(packets[1]); model.Update(); Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); Assert.Equal(1, model.OutOfOrderPacketCount); Assert.Equal(1u, model.LastReceivedPacketSequence); // Second arrival past the gap: desired+2 (4) <= 4 → NAK fires, cleartext, // flags exactly RequestRetransmit, listing only the truly missing id. model.Receive(packets[2]); model.Update(); byte[] nak = Assert.Single( OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); Assert.Equal(new uint[] { 2u }, NakIds(nak)); // Within the 1 s limit (:359) another eligible arrival does NOT re-NAK. model.Receive(packets[3]); model.Update(); Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); // Just under the limit: still closed. clock.Advance(TimeSpan.FromSeconds(0.9)); model.Receive(packets[4]); model.Update(); Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); // EXACTLY 1 s: ACE's comparison is strict (`> new TimeSpan(0, 0, 1)`, // :359), so the boundary itself is still closed. clock.Advance(TimeSpan.FromSeconds(0.1)); model.Receive(packets[5]); model.Update(); Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); // Limiter reopens strictly after 1 s. clock.Advance(TimeSpan.FromSeconds(0.1)); model.Receive(packets[6]); model.Update(); byte[] second = Assert.Single( OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); Assert.Equal(new uint[] { 2u }, NakIds(second)); } [Fact] public void ValidResend_IsAccepted_AndOrderingRestored() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[][] packets = BuildSequentialPackets(client, 3); // seq 2(w1), 3(w2), 4(w3) model.Receive(packets[0]); // in order model.Receive(packets[2]); // out of order: Search parks w2, consumes w3 Assert.Single(model.DispatchedMessages); Assert.Equal(255, model.Crypto.Headroom); // A CORRECT retransmission is byte-identical (same sequence, same // keystream word). The parked key verifies it (CryptoSystem.cs:36-39) // and ConsumeKey un-parks it — the window fully recovers, and the // buffered packet replays in order (NetworkSession.cs:559-566). model.Receive(packets[1]); Assert.Equal(new byte[] { 2, 3, 4 }, Markers(model)); Assert.Equal(4u, model.LastReceivedPacketSequence); Assert.Equal(0, model.OutOfOrderPacketCount); Assert.Equal(256, model.Crypto.Headroom); Assert.Equal(0, model.Crypto.OrphanCount); } [Fact] public void ReKeyedResend_PermanentlyOrphansAKeystreamWord() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[][] packets = BuildSequentialPackets(client, 3); // seq 2(w1), 3(w2), 4(w3) model.Receive(packets[0]); model.Receive(packets[2]); // parks w2 for the pending retransmission Assert.Equal(255, model.Crypto.Headroom); // The buggy client re-keys the resend of seq 3: a fresh encode draws // w4 — which is exactly the server's CurrentKey (the gap walk mirrored // the client's consumption), so ACE ACCEPTS the packet... but the // parked ORIGINAL w2 is now orphaned: no future packet will ever // present it, and the 256-key window is one slot smaller FOREVER. // This is campaign doc §3 row 2 / landmine #2: NEVER re-key a resend — // every loss+re-key cycle burns another slot until the window is gone. byte[] rekeyed = client.BuildGameMessagePacket( packetSequence: 3, fragmentSequence: 2, MakeMessage(3)); model.Receive(rekeyed); Assert.Equal(new byte[] { 2, 3, 4 }, Markers(model)); // accepted, ordering restored Assert.Equal(255, model.Crypto.Headroom); Assert.Equal(1, model.Crypto.OrphanCount); // Healthy follow-on traffic never recovers the orphan. model.Receive(client.BuildGameMessagePacket(MakeMessage(5))); // seq 5 model.Receive(client.BuildGameMessagePacket(MakeMessage(6))); // seq 6 Assert.Equal(new byte[] { 2, 3, 4, 5, 6 }, Markers(model)); Assert.Equal(255, model.Crypto.Headroom); Assert.Equal(1, model.Crypto.OrphanCount); } [Fact] public void ResendOfAlreadyAcceptedPacket_BurnsTheSearchWindow() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[][] packets = BuildSequentialPackets(client, 2); // seq 2(w1), 3(w2) model.Receive(packets[0]); // accepted — w1 consumed, wheel at w2 Assert.Single(model.DispatchedMessages); // An UNREQUESTED duplicate of an already-accepted packet: VerifyCRC // runs BEFORE dedup (NetworkSession.cs:277 vs :342), and w1 is now // BEHIND the wheel — Search walks the entire remaining window // (parking all 256 keys) and fails. Silent drop, window at zero. // Campaign doc §3 row 2 / landmine #3: never resend unrequested. model.Receive(packets[0]); Assert.Equal(1, model.CrcDropCount); Assert.Single(model.DispatchedMessages); Assert.Equal(0, model.Crypto.Headroom); Assert.Equal(256, model.Crypto.OrphanCount); // ACE's parked set doubles as the recovery path: the next healthy // packet's key (w2) was parked during the walk, so it still verifies // and un-parks — the window drains back one packet at a time. model.Receive(packets[1]); Assert.Equal(2, model.DispatchedMessages.Count); Assert.Equal(1, model.Crypto.Headroom); } [Fact] public void AckOnlyPacketAtSameSequence_AcceptedWithoutAdvancingWatermark() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); // The negotiated model has one cached S2C packet: the immediate // first TimeSync at sequence 2. Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray()); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // client seq 2 → watermark 2 Assert.Equal(2u, model.LastReceivedPacketSequence); model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue); model.Update(); // flushes as S2C sequence 3, cached Assert.Equal(2, model.CachedPacketCount); // acdream's acks reuse the last issued client sequence, so they land // AT the watermark: accepted via the exact-equality exemption // (NetworkSession.cs:342-343), the ack VALUE prunes the S2C cache // strictly below it (:663-673), and the watermark does NOT advance // (:474-476: Flags == AckSequence exactly). model.Receive(client.BuildCleartextAck(headerSequence: 2, ackValue: 3)); Assert.Equal(0, model.DuplicateDropCount); Assert.Equal(2u, model.LastReceivedPacketSequence); Assert.Equal(new uint[] { 3u }, model.CachedPacketSequences.ToArray()); // Repeatable at the same sequence. model.Receive(client.BuildCleartextAck(2, 4)); Assert.Equal(0, model.DuplicateDropCount); Assert.Empty(model.CachedPacketSequences); Assert.Equal(2u, model.LastReceivedPacketSequence); // The exemption is equality, not <=: an ack at an OLDER sequence is // rejected as a duplicate. model.Receive(client.BuildCleartextAck(1, 4)); Assert.Equal(1, model.DuplicateDropCount); } [Fact] public void CleartextNonAckAdvancesWatermark_TheAceHole() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[][] packets = BuildSequentialPackets(client, 2); // seq 2(w1), 3(w2) model.Receive(packets[0]); // watermark 2 // THE ACE HOLE (campaign doc §3 row 3, NetworkSession.cs:474-476): // the watermark advances for ANY packet whose flags are not exactly // AckSequence — including a cleartext control packet (here an // EchoRequest keepalive) that reuses a live sequence number. model.Receive(client.BuildCleartextEchoRequest(headerSequence: 3, clientTime: 1.5f)); Assert.Equal(3u, model.LastReceivedPacketSequence); // The REAL packet at sequence 3 arrives: its CRC verifies (the // keystream stays aligned — the word is consumed properly), but the // dedup stage (:342-347) drops the payload. The message is gone // FOREVER and ACE will never NAK it — the self-induced wedge that // forbids standalone non-ack control packets (register AP-125/TS-58). model.Receive(packets[1]); Assert.Equal(1, model.DuplicateDropCount); Assert.Single(model.DispatchedMessages); Assert.Equal(3u, model.LastReceivedPacketSequence); Assert.Equal(256, model.Crypto.Headroom); // no orphan — the loss is pure payload } [Fact] public void FragmentGate_StallsOnGap_AndHealsWhenMissingFragmentArrives() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); // Three in-order PACKETS carrying out-of-order FRAGMENT sequences: // packet 2 → fragment 1, packet 3 → fragment 3, packet 4 → fragment 2. // This isolates the C2S fragment gate (NetworkSession.cs:532-543) // from packet-level reordering. (The packet-retransmission flavor of // the heal is covered by ValidResend_IsAccepted_AndOrderingRestored.) byte[] first = client.BuildGameMessagePacket(2, 1, MakeMessage(1)); byte[] third = client.BuildGameMessagePacket(3, 3, MakeMessage(3)); byte[] second = client.BuildGameMessagePacket(4, 2, MakeMessage(2)); model.Receive(first); Assert.Equal(new byte[] { 1 }, Markers(model)); // The packet is accepted (in order at the packet level) but the // completed message stalls silently behind the gate. model.Receive(third); Assert.Equal(3u, model.LastReceivedPacketSequence); Assert.Equal(new byte[] { 1 }, Markers(model)); Assert.Equal(1, model.FragmentGateBufferCount); Assert.Equal(1u, model.LastReceivedFragmentSequence); // The missing fragment arrives (here aboard the next packet — on a // real link, via packet retransmission): the gate dispatches it and // drains the parked fragment in order (:571-578). model.Receive(second); Assert.Equal(new byte[] { 1, 2, 3 }, Markers(model)); Assert.Equal(0, model.FragmentGateBufferCount); Assert.Equal(3u, model.LastReceivedFragmentSequence); } // ===================================================================== // Multi-fragment C2S reassembly — NetworkSession.ProcessFragment // (:483-518) over ACE's MessageBuffer (MessageBuffer.cs:7-54) // ===================================================================== [Fact] public void SplitC2SMessage_StaysIncompleteUntilTheDroppedPacketIsRedelivered() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[] partA = { 0x11, 0x22, 0x33, 0x44 }; byte[] partB = { 0x55, 0x66, 0x77, 0x88 }; // One logical message split across two packets (fragment sequence 1, // Count 2), then two ordinary follow-on messages. Built in send order // so each draws its own outbound keystream word. byte[] head = client.BuildFragmentPacket(2, fragmentSequence: 1, count: 2, index: 0, partA); byte[] tail = client.BuildFragmentPacket(3, fragmentSequence: 1, count: 2, index: 1, partB); byte[] third = client.BuildGameMessagePacket(4, 2, MakeMessage(4)); byte[] fourth = client.BuildGameMessagePacket(5, 3, MakeMessage(5)); model.Receive(head); Assert.Equal(1, model.PartialFragmentBufferCount); Assert.Empty(model.DispatchedMessages); // `tail` is lost. Everything behind it stacks up at the packet level // and ACE NAKs the hole; the half-built message just sits there. model.Receive(third); model.Receive(fourth); model.Update(); byte[] nak = Assert.Single( OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); Assert.Equal(new uint[] { 3u }, NakIds(nak)); Assert.Equal(1, model.PartialFragmentBufferCount); Assert.Empty(model.DispatchedMessages); Assert.Equal(0u, model.LastReceivedFragmentSequence); // Redelivery completes the message and drains everything behind it. model.Receive(tail); Assert.Equal(3, model.DispatchedMessages.Count); Assert.Equal(partA.Concat(partB).ToArray(), model.DispatchedMessages[0]); Assert.Equal(new byte[] { 4, 5 }, model.DispatchedMessages.Skip(1).Select(MessageMarker).ToArray()); Assert.Equal(0, model.PartialFragmentBufferCount); Assert.Equal(0, model.OutOfOrderPacketCount); Assert.Equal(3u, model.LastReceivedFragmentSequence); Assert.Equal(256, model.Crypto.Headroom); // the parked key was recovered } [Fact] public void SplitC2SMessage_UnderFourBytes_IsDroppedAndStallsTheFragmentGate() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); // Two 1-byte fragments assemble to 2 bytes — under the 4-byte // ClientMessage minimum, so MessageBuffer.TryGetMessage returns null // (MessageBuffer.cs:49-50). ACE removes the buffer anyway (:504-506) // and, because `message` is null, never advances the fragment gate. model.Receive(client.BuildFragmentPacket(2, 1, 2, 0, new byte[] { 0xAA })); model.Receive(client.BuildFragmentPacket(3, 1, 2, 1, new byte[] { 0xBB })); Assert.Empty(model.DispatchedMessages); Assert.Equal(0, model.PartialFragmentBufferCount); Assert.Equal(0u, model.LastReceivedFragmentSequence); Assert.Equal(0, model.CrcDropCount); // The hole is permanent: every later message parks behind it forever // (ACE bug-for-bug — only a fresh session recovers). model.Receive(client.BuildGameMessagePacket(4, 2, MakeMessage(4))); Assert.Empty(model.DispatchedMessages); Assert.Equal(1, model.FragmentGateBufferCount); Assert.Equal(4u, model.LastReceivedPacketSequence); } [Fact] public void SplitC2SMessage_ToleratesLaterFragmentWithLargerCountAndIndex() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[] partA = { 0x11, 0x22, 0x33, 0x44 }; byte[] partB = { 0x55, 0x66, 0x77, 0x88 }; // ACE's MessageBuffer takes TotalFragments from the FIRST fragment it // sees and completes on a COUNT match over a List (MessageBuffer.cs:9, // :14, :22-31). A later fragment claiming Count 3 / Index 2 neither // resizes the buffer nor lands out of range — it is simply the second // entry, which completes the message. model.Receive(client.BuildFragmentPacket(2, 1, count: 2, index: 0, partA)); model.Receive(client.BuildFragmentPacket(3, 1, count: 3, index: 2, partB)); byte[] assembled = Assert.Single(model.DispatchedMessages); Assert.Equal(partA.Concat(partB).ToArray(), assembled); // sorted by Index (:38) Assert.Equal(0, model.PartialFragmentBufferCount); Assert.Equal(1u, model.LastReceivedFragmentSequence); Assert.Equal(0, model.CrcDropCount); } [Fact] public void ZeroCountFragment_IsAcceptedByTheParse_ThenSilentlyDropped() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); byte[] zeroCount = client.BuildFragmentPacket(2, 1, count: 0, index: 0, MakeMessage(0x77)); // acdream's PRODUCTION parser refuses this shape // (MessageFragment.TryParseLayout rejects Count == 0)... Assert.Equal( PacketCodec.DecodeError.InvalidFragment, PacketCodec.TryDecode(zeroCount, inboundIsaac: null).Error); // ...while ACE's ClientPacketFragment.Unpack (:10-23) only checks // 16 ≤ Size ≤ 464, so the packet is parsed, CRC-verified and // processed. ProcessFragment takes the split branch (Count != 1) // and its NEW-buffer arm (:509-518), which never checks Complete: // MessageBuffer.AddFragment refuses to add to the already- // "Complete" zero-count buffer, no message ever dispatches, and the // dead buffer stays PARKED in partialFragments forever. The packet // still burns its keystream word and still advances the watermark. model.Receive(zeroCount); Assert.Equal(0, model.CrcDropCount); Assert.Empty(model.DispatchedMessages); Assert.Equal(1, model.PartialFragmentBufferCount); Assert.Equal(0u, model.LastReceivedFragmentSequence); Assert.Equal(2u, model.LastReceivedPacketSequence); } // ===================================================================== // Termination + timeout // ===================================================================== [Fact] public void Termination_KeepsRunningForTwoSeconds_ThenReleases() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); Assert.Single(model.DispatchedMessages); // Session.Terminate (Session.cs:281-298) only ARMS PendingTermination // with a 2 s window (SessionTerminationDetails.cs:12). model.Receive(TransportDisconnect.Build((ushort)ClientId, iteration: 1)); Assert.True(model.IsTerminated); Assert.False(model.IsReleased); Assert.Equal(AceTerminationPhase.Initialized, model.TerminationPhase); Assert.Equal(AceTerminationReason.PacketHeaderDisconnect, model.TerminationReason); // Phase 1 (Session.cs:126-131): inbound still processes... clock.Advance(TimeSpan.FromSeconds(1)); model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); Assert.Equal(2, model.DispatchedMessages.Count); // ...and Network.Update() still runs, so queued messages still leave // ("boot messages may need sending", :129). model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue); model.Update(); Assert.False(model.IsReleased); byte[] flushed = Assert.Single(model.TakePendingDatagrams()); Assert.Equal( PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, Head(flushed).Flags); // Past TerminationEndTicks the pump completes the session work and // DropSession releases the network resources (:130-131, :300-334). clock.Advance(TimeSpan.FromSeconds(1.2)); model.Update(); Assert.True(model.IsReleased); Assert.Equal(AceTerminationPhase.SessionWorkCompleted, model.TerminationPhase); model.TakePendingDatagrams(); // that pump's due cumulative ack // Released (NetworkSession.cs:271-272, :184-185): inbound and outbound // are both no-ops. model.Receive(client.BuildGameMessagePacket(MakeMessage(4))); model.Update(); Assert.Equal(2, model.DispatchedMessages.Count); Assert.Empty(model.TakePendingDatagrams()); } [Fact] public void SixtySecondTimeout_Terminates_AndCleartextNaksDoNotRefreshIt() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // refresh → +60 s (:329-331) long deadline = model.TimeoutDeadlineTimestamp; clock.Advance(TimeSpan.FromSeconds(59)); // A cleartext NAK is handled and RETURNS before the timeout refresh // (:283-308) — it does NOT extend the deadline. (Id 2 is the cached // initial TimeSync, so this one is served, proving the path ran.) model.Receive(client.BuildCleartextNak(2, 2u)); Assert.Equal(1, model.RetransmitsServed); Assert.Equal(deadline, model.TimeoutDeadlineTimestamp); model.Update(); Assert.False(model.IsTerminated); // ACE compares `DateTime.UtcNow.Ticks >= Network.TimeoutTick` // (Session.cs:140): the boundary itself kills the session. clock.Advance(TimeSpan.FromSeconds(1)); Assert.Equal(deadline, clock.GetTimestamp()); model.TakePendingDatagrams(); model.Update(); Assert.True(model.IsTerminated); Assert.Equal(AceTerminationReason.NetworkTimeout, model.TerminationReason); // Every ACE transport death is silence — no disconnect packet is sent. Assert.Empty(model.TakePendingDatagrams()); } [Fact] public void GapBeyondSearchWindow_TerminatesAbnormalSequenceReceived() { // Boundary: watermark 1 → desired 2 → bottom 3. Arrived 259 keeps // rcvd − bottom == 256 (not > MaximumEffortLevel) → a NAK capped at // 115 ids (NetworkSession.cs:381, :398-410). (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); model.Receive(client.BuildGameMessagePacket(259, 1, MakeMessage(1))); Assert.False(model.IsTerminated); model.Update(); byte[] nak = Assert.Single( OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); uint[] ids = NakIds(nak); Assert.Equal(115, ids.Length); Assert.Equal(2u, ids[0]); // desiredSeq leads the list (:390-391) Assert.Equal(116u, ids[^1]); // then 3..116 — the 115-id cap // One past the window: rcvd − bottom > 256 → AbnormalSequenceReceived // (:393-397), and no NAK goes out. (AceSessionModel model2, TestAcClient client2, _) = CreateNegotiatedModel(); model2.Receive(client2.BuildGameMessagePacket(260, 1, MakeMessage(1))); Assert.True(model2.IsTerminated); Assert.Equal(AceTerminationReason.AbnormalSequenceReceived, model2.TerminationReason); model2.Update(); Assert.Empty(OfExactFlags(model2.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); } // ===================================================================== // Send side — retransmit, ack, echo, cache prune, bundling // ===================================================================== [Fact] public void Retransmit_ServesCachedBytes_WithRetransmissionFlag_AndNoNewIsaacWord() { (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); // Shadow the S2C keystream: word 1 went to the immediate TimeSync the // negotiation helper drained. IsaacRandom shadow = MakeIsaac(ServerSeed); uint w1 = shadow.Next(); uint w2 = shadow.Next(); uint w3 = shadow.Next(); uint w4 = shadow.Next(); Assert.NotEqual(w1, w2); // sanity on the shadow itself model.EnqueueGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); model.Update(); byte[] packetA = Assert.Single(model.TakePendingDatagrams()); Assert.Equal(3u, Head(packetA).Sequence); // TimeSync took 2; UIntSequence increments Assert.Equal(w2, ExtractIsaacKey(packetA)); model.EnqueueGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); model.Update(); byte[] packetB = Assert.Single(model.TakePendingDatagrams()); Assert.Equal(w3, ExtractIsaacKey(packetB)); // Cleartext NAK for sequence 3 → IMMEDIATE retransmit from the cache // (NetworkSession.cs:675-686): Retransmission OR'd into the flags, // body bytes untouched, ORIGINAL keystream word reused, Time kept. model.Receive(client.BuildCleartextNak(2, 3u)); byte[] resent = Assert.Single(model.TakePendingDatagrams()); PacketHeader resentHeader = Head(resent); Assert.Equal(3u, resentHeader.Sequence); Assert.Equal( PacketHeaderFlags.Retransmission | PacketHeaderFlags.EncryptedChecksum | PacketHeaderFlags.BlobFragments, resentHeader.Flags); Assert.Equal( packetA.AsSpan(PacketHeader.Size).ToArray(), resent.AsSpan(PacketHeader.Size).ToArray()); Assert.Equal(w2, ExtractIsaacKey(resent)); Assert.Equal(Head(packetA).Time, resentHeader.Time); Assert.Equal(1, model.RetransmitsServed); // The S2C keystream was not disturbed: the next fresh packet uses w4. model.EnqueueGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); model.Update(); byte[] packetC = Assert.Single(model.TakePendingDatagrams()); Assert.Equal(w4, ExtractIsaacKey(packetC)); // A NAK for an id that was never cached → RejectRetransmit (:299-304). model.Receive(client.BuildCleartextNak(2, 40u)); model.Update(); byte[] reject = Assert.Single( model.TakePendingDatagrams(), d => (Head(d).Flags & PacketHeaderFlags.RejectRetransmit) != 0); Assert.Equal(new uint[] { 40u }, RejectIds(reject)); } [Fact] public void CumulativeAck_EveryTwoSeconds_CleartextExactFlags_ReusedSequence() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); // watermark 3 model.Update(); Assert.Empty(model.TakePendingDatagrams()); // 2 s gate not due (:55, :211) clock.Advance(TimeSpan.FromSeconds(2.1)); model.Update(); byte[] ack = Assert.Single(model.TakePendingDatagrams()); PacketHeader ackHeader = Head(ack); // Cleartext, flags EXACTLY AckSequence (:925-931), sequence REUSED — // the ack borrows the current S2C sequence without incrementing // (:722-723; the initial TimeSync holds sequence 2). Assert.Equal(PacketHeaderFlags.AckSequence, ackHeader.Flags); Assert.Equal(2u, ackHeader.Sequence); Assert.Equal( 3u, BinaryPrimitives.ReadUInt32LittleEndian(ack.AsSpan(PacketHeader.Size))); model.Update(); // gate re-armed (:215) — no second ack Assert.Empty(model.TakePendingDatagrams()); // The ack really did not consume a sequence: the next message takes 3. model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue); model.Update(); Assert.Equal(3u, Head(Assert.Single(model.TakePendingDatagrams())).Sequence); } [Fact] public void EchoRequest_GetsEchoResponse() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); model.Receive(client.BuildCleartextEchoRequest(headerSequence: 2, clientTime: 5.5f)); clock.Advance(TimeSpan.FromSeconds(0.5)); model.Update(); // FlagEcho (:440-443, :650-661) → EchoResponse on the next control // flush (:941-948): float clientTime + float (serverNow − clientTime), // EncryptedChecksum forced. byte[] echo = Assert.Single(model.TakePendingDatagrams()); Assert.Equal( PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum, Head(echo).Flags); Assert.Equal( 5.5f, BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size))); Assert.Equal( 0.5f - 5.5f, BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size + 4))); } [Fact] public void SendBundle_CoalescesSmallMessagesIntoOnePacket() { (AceSessionModel model, _, _) = CreateNegotiatedModel(); IsaacRandom shadow = MakeIsaac(ServerSeed); shadow.Next(); // w1 — the negotiation TimeSync uint w2 = shadow.Next(); uint w3 = shadow.Next(); // Three messages enqueued into the same bundle before one pump. model.EnqueueGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); model.EnqueueGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); model.EnqueueGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); model.Update(); // NetworkSession.SendBundle (:828-903) packs everything that fits into // ONE 464-byte packet: one sequence, one keystream word, three // fragments carrying three consecutive fragment sequences (:821). byte[] packet = Assert.Single(model.TakePendingDatagrams()); PacketHeader header = Head(packet); Assert.Equal(3u, header.Sequence); Assert.Equal( PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, header.Flags); Assert.Equal(w2, ExtractIsaacKey(packet)); MessageFragment[] fragments = FragmentsOf(packet); Assert.Equal(3, fragments.Length); Assert.Equal(new uint[] { 0u, 1u, 2u }, fragments.Select(f => f.Header.Sequence).ToArray()); Assert.All(fragments, f => Assert.Equal(1, (int)f.Header.Count)); Assert.All(fragments, f => Assert.Equal(0, (int)f.Header.Index)); Assert.All(fragments, f => Assert.Equal(GameMessageFragment.OutboundFragmentId, f.Header.Id)); Assert.Equal( new byte[] { 0xA1, 0xB2, 0xC3 }, fragments.Select(f => f.Payload[0]).ToArray()); // Exactly one word was consumed by the whole bundle: the next packet // takes the next one. model.EnqueueGameMessage(MakeMessage(0xD4), GameMessageGroup.UIQueue); model.Update(); byte[] next = Assert.Single(model.TakePendingDatagrams()); Assert.Equal(4u, Head(next).Sequence); Assert.Equal(w3, ExtractIsaacKey(next)); Assert.Equal(3u, Assert.Single(FragmentsOf(next)).Header.Sequence); } [Fact] public void SendBundle_SplitsLargeMessageAcrossPacketsWithCountGreaterThanOne() { (AceSessionModel model, _, _) = CreateNegotiatedModel(); IsaacRandom shadow = MakeIsaac(ServerSeed); shadow.Next(); // w1 — the negotiation TimeSync uint w2 = shadow.Next(); uint w3 = shadow.Next(); // 600 bytes > MaxFragmentDataSize (448) → Count = ceil(600/448) = 2 // (MessageFragment.cs:47). byte[] large = MakeLargeMessage(600); model.EnqueueGameMessage(large, GameMessageGroup.UIQueue); model.Update(); List sent = model.TakePendingDatagrams(); Assert.Equal(2, sent.Count); Assert.Equal(new uint[] { 3u, 4u }, sent.Select(d => Head(d).Sequence).ToArray()); Assert.Equal(w2, ExtractIsaacKey(sent[0])); Assert.Equal(w3, ExtractIsaacKey(sent[1])); // :846-854 — the head fills a packet alone; :874-880 — the tail rides // the next one. Both carry the SAME fragment sequence and Count 2. MessageFragment head = Assert.Single(FragmentsOf(sent[0])); MessageFragment tail = Assert.Single(FragmentsOf(sent[1])); Assert.Equal(2, (int)head.Header.Count); Assert.Equal(0, (int)head.Header.Index); Assert.Equal(MessageFragmentHeader.MaxFragmentDataSize, head.Payload.Length); Assert.Equal(2, (int)tail.Header.Count); Assert.Equal(1, (int)tail.Header.Index); Assert.Equal(600 - MessageFragmentHeader.MaxFragmentDataSize, tail.Payload.Length); Assert.Equal(head.Header.Sequence, tail.Header.Sequence); Assert.Equal(large, head.Payload.Concat(tail.Payload).ToArray()); } [Fact] public void CachedPackets_PruneAfter120Seconds_ThenStaleNakGetsRejectRetransmit() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray()); // the t=0 TimeSync // Keep the session alive with periodic client packets (each refreshes // the 60 s deadline). clock.Advance(TimeSpan.FromSeconds(50)); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); clock.Advance(TimeSpan.FromSeconds(50)); model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); model.Update(); // t = 100 s: prune runs, the seq-2 entry is well inside Assert.Contains(2u, model.CachedPacketSequences); // The retention test is STRICTLY greater than 120 (:258), so at // exactly 120 s the entry survives. clock.Advance(TimeSpan.FromSeconds(20)); model.Receive(client.BuildGameMessagePacket(MakeMessage(4))); model.Update(); Assert.Contains(2u, model.CachedPacketSequences); // The next prune cannot run until the 5 s prune interval elapses // (:187-188, :67), so the removal probe lands at 125.1 s. clock.Advance(TimeSpan.FromSeconds(5.1)); model.Receive(client.BuildGameMessagePacket(MakeMessage(5))); model.Update(); Assert.DoesNotContain(2u, model.CachedPacketSequences); // A stale NAK for the pruned id → RejectRetransmit — the §3 row // "S2C cache prunes at 120 s; old NAKs get RejectRetransmit". model.Receive(client.BuildCleartextNak(6, 2u)); model.Update(); byte[] reject = Assert.Single( model.TakePendingDatagrams(), d => (Head(d).Flags & PacketHeaderFlags.RejectRetransmit) != 0); Assert.Equal(new uint[] { 2u }, RejectIds(reject)); Assert.Equal(0, model.RetransmitsServed); } [Fact] public void ConnectRequest_MatchesNegotiationFixtureLayout() { var clock = new VirtualClock(); var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie); model.LoginRequestReceived += model.SendConnectRequest; model.Receive(BuildLoginRequest()); model.Update(); // The 32-byte optional layout must match what WorldSession.Connect // parses (and what WorldSessionNegotiationShutdownTests. // BuildConnectRequest pins): serverTime, cookie, clientId, // serverSeed, clientSeed, padding. byte[] connectRequest = Assert.Single(model.TakePendingDatagrams()); PacketCodec.PacketDecodeResult decoded = PacketCodec.TryDecode(connectRequest, inboundIsaac: null); Assert.True(decoded.IsOk, decoded.Error.ToString()); Packet packet = decoded.Packet!; Assert.True(packet.Header.HasFlag(PacketHeaderFlags.ConnectRequest)); Assert.Equal(0u, packet.Header.Sequence); // first NextValue of the unprimed UIntSequence Assert.Equal((ushort)1, packet.Header.Iteration); Assert.Equal(Cookie, packet.Optional.ConnectRequestCookie); Assert.Equal(ClientId, packet.Optional.ConnectRequestClientId); Assert.Equal(ServerSeed, packet.Optional.ConnectRequestServerSeed); Assert.Equal(ClientSeed, packet.Optional.ConnectRequestClientSeed); } // ===================================================================== // Fixture helpers // ===================================================================== /// /// A model with the handshake completed the way a real session does it: /// LoginRequest → ConnectRequest (flushed + discarded; primes the S2C /// sequence to 0 and moves the state to AuthConnectResponse) → /// ConnectResponse → the immediate first TimeSync (flushed + discarded; /// S2C sequence 2, S2C keystream word 1, cached). /// private static (AceSessionModel Model, TestAcClient Client, VirtualClock Clock) CreateNegotiatedModel() { var clock = new VirtualClock(); var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie); model.LoginRequestReceived += model.SendConnectRequest; model.Receive(BuildLoginRequest()); model.Update(); model.TakePendingDatagrams(); // discard the ConnectRequest (sequence 0) model.Receive(BuildConnectResponse()); model.Update(); model.TakePendingDatagrams(); // discard the immediate first TimeSync (sequence 2) return (model, new TestAcClient(ClientSeed), clock); } private static byte[] BuildLoginRequest() { byte[] payload = LoginRequest.Build("testaccount", "testpassword", 1234); return PacketCodec.Encode( new PacketHeader { Flags = PacketHeaderFlags.LoginRequest }, payload, outboundIsaac: null); } private static byte[] BuildConnectResponse() { byte[] body = new byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(body, Cookie); return PacketCodec.Encode( new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse }, body, outboundIsaac: null); } /// Sequential post-handshake game-message packets: sequences 2.., /// fragment sequences 1.., one keystream word each, marker = index + 2. private static byte[][] BuildSequentialPackets(TestAcClient client, int count) => Enumerable.Range(0, count) .Select(i => client.BuildGameMessagePacket(MakeMessage((byte)(i + 2)))) .ToArray(); /// 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 }; /// A message body too large for one fragment, with recognizable content. private static byte[] MakeLargeMessage(int length) { byte[] body = new byte[length]; for (int i = 0; i < length; i++) body[i] = (byte)(i * 7 + 3); return body; } private static byte MessageMarker(byte[] messageBody) => messageBody[0]; private static byte[] Markers(AceSessionModel model) => model.DispatchedMessages.Select(MessageMarker).ToArray(); private static PacketHeader Head(byte[] datagram) => PacketHeader.Unpack(datagram); private static List OfExactFlags( IEnumerable datagrams, PacketHeaderFlags flags) => datagrams.Where(d => Head(d).Flags == flags).ToList(); /// Every fragment carried by a datagram, in wire order. private static MessageFragment[] FragmentsOf(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); var fragments = new List(); ReadOnlySpan remaining = body.Slice(consumed); while (!remaining.IsEmpty) { (MessageFragment? fragment, int fragmentBytes) = MessageFragment.TryParse(remaining); Assert.NotNull(fragment); fragments.Add(fragment!.Value); remaining = remaining.Slice(fragmentBytes); } return fragments.ToArray(); } private static uint[] NakIds(byte[] nakDatagram) { PacketCodec.PacketDecodeResult decoded = PacketCodec.TryDecode(nakDatagram, inboundIsaac: null); Assert.True(decoded.IsOk, decoded.Error.ToString()); return decoded.Packet!.Optional.RetransmitRequests.ToArray(); } /// RejectRetransmit body: u32 count + ids (PacketRejectRetransmit.cs:7-17). private static uint[] RejectIds(byte[] rejectDatagram) { ReadOnlySpan body = rejectDatagram.AsSpan(PacketHeader.Size); uint count = BinaryPrimitives.ReadUInt32LittleEndian(body); var ids = new uint[count]; for (int i = 0; i < ids.Length; i++) ids[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4 + i * 4)); return ids; } /// /// Recover the ISAAC word from an encrypted datagram's checksum: /// key = (checksum − headerHash) ^ payloadHash (ClientPacket.cs:142). /// Returns 0 for cleartext packets. /// 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; } private static IsaacRandom MakeIsaac(uint seed) { Span seedBytes = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); return new IsaacRandom(seedBytes); } /// /// The client half of the conversation: builds wire-true packets with the /// same primitives WorldSession uses (GameMessageFragment + /// PacketCodec.Encode), drawing exactly one outbound keystream word per /// encrypted encode — so "loss" is simulated by building in order and /// simply not delivering. /// private sealed class TestAcClient { private readonly IsaacRandom _outboundIsaac; /// WorldSession.cs:868 — the post-handshake reliable stream starts at 2. public uint PacketSequence = 2; /// WorldSession.cs:680 — fragment sequence starts at 1. public uint FragmentSequence = 1; public TestAcClient(uint clientSeed) => _outboundIsaac = MakeIsaac(clientSeed); public byte[] BuildGameMessagePacket(byte[] messageBody) => BuildGameMessagePacket(PacketSequence++, FragmentSequence++, messageBody); public byte[] BuildGameMessagePacket( uint packetSequence, uint fragmentSequence, byte[] messageBody) { byte[] fragment = GameMessageFragment.Serialize( GameMessageFragment.BuildSingleFragment( fragmentSequence, GameMessageGroup.UIQueue, messageBody)); var header = new PacketHeader { Sequence = packetSequence, Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, Id = (ushort)ClientId, }; return PacketCodec.Encode(header, fragment, _outboundIsaac); } /// /// One packet carrying one arbitrarily-shaped fragment. Hand-rolled /// (rather than ) because acdream's /// production encoder refuses shapes ACE happily accepts — notably /// Count == 0 — and the double's parse path exists to /// characterize ACE. The checksum arithmetic mirrors /// PacketCodec.FinalizeInPlace exactly. /// public byte[] BuildFragmentPacket( uint packetSequence, uint fragmentSequence, ushort count, ushort index, byte[] payload) { var fragmentHeader = new MessageFragmentHeader { Sequence = fragmentSequence, Id = GameMessageFragment.OutboundFragmentId, Count = count, TotalSize = (ushort)(MessageFragmentHeader.Size + payload.Length), Index = index, Queue = (ushort)GameMessageGroup.UIQueue, }; int bodyLength = MessageFragmentHeader.Size + payload.Length; byte[] datagram = new byte[PacketHeader.Size + bodyLength]; fragmentHeader.Pack(datagram.AsSpan(PacketHeader.Size)); payload.CopyTo(datagram.AsSpan(PacketHeader.Size + MessageFragmentHeader.Size)); var header = new PacketHeader { Sequence = packetSequence, Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, Id = (ushort)ClientId, DataSize = (ushort)bodyLength, }; uint payloadHash = PacketCodec.CalculateFragmentHash32( new MessageFragment(fragmentHeader, payload)); header.Checksum = header.CalculateHeaderHash32() + (_outboundIsaac.Next() ^ payloadHash); header.Pack(datagram); return datagram; } public byte[] BuildCleartextAck(uint headerSequence, uint ackValue) => BuildAck(headerSequence, ackValue, encrypted: false); /// /// An ack whose flags are AckSequence|EncryptedChecksum — NOT the exact /// AckSequence value, so it is a normal sequenced packet that consumes a /// keystream word and advances ACE's watermark (:474-476). /// public byte[] BuildEncryptedAck(uint headerSequence, uint ackValue) => BuildAck(headerSequence, ackValue, encrypted: true); private byte[] BuildAck(uint headerSequence, uint ackValue, bool encrypted) { byte[] body = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(body, ackValue); return PacketCodec.Encode( new PacketHeader { Sequence = headerSequence, Flags = encrypted ? PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum : PacketHeaderFlags.AckSequence, Id = (ushort)ClientId, }, body, encrypted ? _outboundIsaac : null); } public byte[] BuildCleartextNak(uint headerSequence, params uint[] ids) { byte[] body = new byte[4 + ids.Length * 4]; BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)ids.Length); for (int i = 0; i < ids.Length; i++) BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + i * 4), ids[i]); return PacketCodec.Encode( new PacketHeader { Sequence = headerSequence, Flags = PacketHeaderFlags.RequestRetransmit, Id = (ushort)ClientId, }, body, outboundIsaac: null); } public byte[] BuildCleartextEchoRequest(uint headerSequence, float clientTime) { byte[] body = new byte[4]; BinaryPrimitives.WriteSingleLittleEndian(body, clientTime); return PacketCodec.Encode( new PacketHeader { Sequence = headerSequence, Flags = PacketHeaderFlags.EchoRequest, Id = (ushort)ClientId, }, body, outboundIsaac: null); } } }