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; [Fact] public void Nak_FiresOnlyAtDesiredPlusTwo_WithOneSecondRateLimit() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); byte[][] packets = BuildSequentialPackets(client, count: 5); // seq 2..6 // 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)); // Limiter reopens strictly after 1 s. clock.Advance(TimeSpan.FromSeconds(1.1)); model.Receive(packets[4]); 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); } [Fact] public void SixtySecondTimeout_Terminates_AndCleartextNaksDoNotRefreshIt() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // refresh → +60 s (:329-331) 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); model.Update(); Assert.False(model.IsTerminated); clock.Advance(TimeSpan.FromSeconds(2)); // 61 s since the last real packet 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)); } [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 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 across 121 s with periodic client packets // (each refreshes the 60 s deadline) but no server pumps. clock.Advance(TimeSpan.FromSeconds(50)); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); clock.Advance(TimeSpan.FromSeconds(50)); model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); clock.Advance(TimeSpan.FromSeconds(21)); model.Receive(client.BuildGameMessagePacket(MakeMessage(4))); model.Update(); // prune (:251-262): the seq-2 packet is 121 s old (> 120) 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(4, 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) → 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 }; 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(); 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); } public byte[] BuildCleartextAck(uint headerSequence, uint ackValue) { byte[] body = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(body, ackValue); return PacketCodec.Encode( new PacketHeader { Sequence = headerSequence, Flags = PacketHeaderFlags.AckSequence, Id = (ushort)ClientId, }, body, 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); } } }