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; /// /// Termination causes the double can hit, mirroring ACE's /// SessionTerminationReason names for the modeled paths. /// internal enum AceTerminationReason { None, /// NetworkSession.cs:312-315 — client sent a Disconnect header. PacketHeaderDisconnect, /// NetworkSession.cs:318-321 — client sent NetErrorDisconnect. ClientSentNetworkErrorDisconnect, /// NetworkSession.cs:393-397 — sequence gap beyond the crypto search window. AbnormalSequenceReceived, /// TimeoutTick (NetworkSession.cs:88, :329-331) expired — every ACE transport death is silence. NetworkTimeout, } /// /// Transport-free model of ACE's per-connection NetworkSession /// receive + send behavior, operating on raw datagrams (byte[]). This is the /// Campaign N test double: slices N1-N5 are graded against it, so every rule /// carries its citation into /// references/ACE/Source/ACE.Server/Network/NetworkSession.cs (or the /// named ACE file). It deliberately reproduces ACE's raw (wrap-unsafe) /// sequence comparisons and the exact-equality flag checks — do NOT "fix" /// them; they are the environment acdream must survive. /// /// /// Time comes exclusively from an injected — no /// wall clock anywhere. The model is single-threaded by design; callers /// (see ) serialize access. /// /// /// /// Intentional simplifications, none affecting the pinned rules: /// /// The initial timeout horizon is the 60 s in-world value; ACE's /// 15 s pre-auth window (NetworkSession.cs:102-103) is not modeled. /// Timeout expiry is checked in (ACE checks /// TimeoutTick from the WorldManager loop). /// The 5 ms inter-bundle pacing delay (NetworkSession.cs:30, :244) /// is not modeled — it is send pacing, not protocol behavior, and /// would deadlock a virtual clock that only tests advance. /// Each enqueued game message flushes as its own packet; ACE /// coalesces same-group fragments up to 464 bytes /// (NetworkSession.cs:828-918). Sequencing/caching semantics are /// identical either way because our messages are all ≤448 B. /// Ack/TimeSync/EchoResponse emission is gated on the handshake /// being complete (ACE cannot address S2C traffic before it knows /// the endpoint; pre-handshake the timers cannot have fired in /// practice). /// /// /// internal sealed class AceSessionModel { // ---- ACE constants, cited ---- /// NetworkSession.cs:381 — max NAK ids per RequestRetransmit. private const int MaxNumNakSeqIds = 115; /// NetworkSession.cs:359 — `new TimeSpan(0, 0, 1)` NAK rate limit. private static readonly long NakRateLimitTicks = TimeSpan.FromSeconds(1).Ticks; /// NetworkSession.cs:32 — timeBetweenAck = 2000 ms. private static readonly long AckIntervalTicks = TimeSpan.FromSeconds(2).Ticks; /// NetworkSession.cs:31 — timeBetweenTimeSync = 20000 ms. private static readonly long TimeSyncIntervalTicks = TimeSpan.FromSeconds(20).Ticks; /// NetworkManager.DefaultSessionTimeout (60 s), applied at NetworkSession.cs:329-331. private static readonly long SessionTimeoutTicks = TimeSpan.FromSeconds(60).Ticks; /// NetworkSession.cs:67 — cachedPacketPruneInterval = 5 s. private static readonly long CachePruneIntervalTicks = TimeSpan.FromSeconds(5).Ticks; /// NetworkSession.cs:72 — cachedPacketRetentionTime = 120 s. private const int CachedPacketRetentionSeconds = 120; // ---- identity / handshake material ---- private readonly VirtualClock _clock; private readonly ushort _serverId; private readonly uint _clientId; private readonly ulong _cookie; private readonly uint _clientSeed; private readonly uint _serverSeed; /// C2S verifier — SessionConnectionData.CryptoClient (SessionConnectionData.cs:61). public AceCryptoModel Crypto { get; } /// S2C keystream — SessionConnectionData.IssacServer (SessionConnectionData.cs:62). private readonly IsaacRandom _s2cKeystream; // ---- receive state ---- /// NetworkSession.cs:57 — starts at 1. private uint _lastReceivedPacketSequence = 1; /// NetworkSession.cs:58 — starts at 0. private uint _lastReceivedFragmentSequence; /// NetworkSession.cs:41 — outOfOrderPackets (parsed + CRC-verified; never re-verified). private readonly Dictionary _outOfOrderPackets = new(); /// NetworkSession.cs:42 — partialFragments (multi-fragment C2S reassembly). private readonly Dictionary _partialFragments = new(); /// NetworkSession.cs:43 — outOfOrderFragments (the C2S fragment gate buffer). private readonly Dictionary _outOfOrderFragments = new(); /// NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null). private long? _lastNakTimestamp; private float? _pendingEchoClientTime; // ---- send state ---- /// /// ACE's ConnectionData.PacketSequence is UIntSequence(clientPrimed:false) /// (SessionConnectionData.cs:66): CurrentValue starts at uint.MaxValue and /// the first NextValue wraps to 0 (UIntSequence.cs:19-41), so the /// cleartext ConnectRequest goes out with sequence 0. The first ENCRYPTED /// flush re-primes CurrentValue to 1 (NetworkSession.cs:716-717), making /// the first encrypted S2C packet sequence 2. /// private uint _packetSequence = uint.MaxValue; /// SessionConnectionData.FragmentSequence — default 0; assigned at bundle flush (NetworkSession.cs:821). private uint _s2cFragmentSequence; /// NetworkSession.cs:65 — cachedPackets, keyed by sequence. private readonly Dictionary _cachedPackets = new(); private long? _lastPruneTimestamp; private long _nextAckTimestamp; private long? _nextResyncTimestamp; private bool _sendResync; private bool _handshakeComplete; private readonly List<(byte[] Body, GameMessageGroup Group)> _pendingMessages = new(); /// NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update. private readonly Queue _flushQueue = new(); // ---- observable outputs ---- private readonly List _dispatchedMessages = new(); private readonly List _sentDatagrams = new(); private readonly Queue _pendingOutbound = new(); public AceSessionModel( VirtualClock clock, uint clientSeed, uint serverSeed, uint clientId, ulong cookie, ushort serverId = 0x000C) { _clock = clock; _clientSeed = clientSeed; _serverSeed = serverSeed; _clientId = clientId; _cookie = cookie; _serverId = serverId; Crypto = new AceCryptoModel(clientSeed); Span seedBytes = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, serverSeed); _s2cKeystream = new IsaacRandom(seedBytes); // NetworkSession.cs:54-55 — sendAck starts true with the 2 s delay armed. _nextAckTimestamp = clock.GetTimestamp() + AckIntervalTicks; // Simplified from NetworkSession.cs:102-103 (15 s pre-auth window); // the double pins the 60 s in-world horizon of :329-331 only. TimeoutDeadlineTimestamp = clock.GetTimestamp() + SessionTimeoutTicks; } // ---- diagnostics for assertions ---- public uint LastReceivedPacketSequence => _lastReceivedPacketSequence; public uint LastReceivedFragmentSequence => _lastReceivedFragmentSequence; /// Fully-assembled C2S message bodies in ACE dispatch order. public IReadOnlyList DispatchedMessages => _dispatchedMessages; /// Every S2C datagram the model has emitted, in send order (cumulative). public IReadOnlyList SentDatagrams => _sentDatagrams; public bool IsTerminated { get; private set; } public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None; /// VirtualClock timestamp past which terminates the session. public long TimeoutDeadlineTimestamp { get; private set; } public int OutOfOrderPacketCount => _outOfOrderPackets.Count; /// Completed messages parked behind the C2S fragment gate (NetworkSession.cs:539-542). public int FragmentGateBufferCount => _outOfOrderFragments.Count; public int PartialFragmentBufferCount => _partialFragments.Count; public int CachedPacketCount => _cachedPackets.Count; public IReadOnlyCollection CachedPacketSequences => _cachedPackets.Keys; /// Packets silently dropped by CRC/Search failure (NetworkSession.cs:277-280). public int CrcDropCount { get; private set; } /// Packets dropped by the duplicate-rejection rule (NetworkSession.cs:342-347). public int DuplicateDropCount { get; private set; } public int RetransmitsServed { get; private set; } // ---- script hooks for FakeAceTransport ---- /// Fired when a LoginRequest packet is handled (NetworkSession.cs:463-468). public event Action? LoginRequestReceived; /// Fired when a cookie-matching ConnectResponse is accepted (NetworkManager.cs:50-79). public event Action? ConnectResponseAccepted; /// Fired per dispatched C2S message body, in ACE dispatch order. public event Action? MessageDispatched; /// Drain the datagrams emitted since the last call, in send order. public List TakePendingDatagrams() { var drained = new List(_pendingOutbound.Count); while (_pendingOutbound.TryDequeue(out byte[]? datagram)) drained.Add(datagram); return drained; } // ===================================================================== // Receive pipeline — NetworkSession.ProcessPacket (:269-379), in ACE's // exact order. // ===================================================================== public void Receive(ReadOnlySpan datagram) { if (IsTerminated) return; // isReleased guard (:271-272) if (!TryParse(datagram, out ParsedPacket packet)) return; // ClientPacket.Unpack failure — ConnectionListener discards silently // ConnectResponse is routed by flag BEFORE the session pipeline // (NetworkManager.cs:50-79): its CRC is never verified (VerifyCRC only // runs inside NetworkSession.ProcessPacket) and no dedup/watermark // applies — the 64-bit cookie is the authenticator // (PacketInboundConnectResponse). if ((packet.Header.Flags & PacketHeaderFlags.ConnectResponse) != 0) { HandleConnectResponse(packet); return; } // 1. CRC verification (:277-280). Failure → silent drop; note the // timeout refresh below is NOT reached, so a CRC-failing flood // cannot keep a session alive. if (!VerifyCrc(packet)) { CrcDropCount++; return; } // 2. Cleartext-NAK early handling (:283-308): RequestRetransmit set // AND EncryptedChecksum NOT set → serve retransmits (immediate raw // sends), queue RejectRetransmit for uncached ids, and RETURN — // before the timeout refresh, so NAKs never refresh ACE's 60 s // timeout. Encrypted NAKs fall through and are effectively // ignored (:283-284 requires the cleartext form). if ((packet.Header.Flags & PacketHeaderFlags.RequestRetransmit) != 0 && (packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) == 0) { List? uncached = null; foreach (uint sequence in packet.Optional.RetransmitRequests) { if (!TryRetransmit(sequence)) (uncached ??= new List()).Add(sequence); } if (uncached is not null) EnqueueRejectRetransmit(uncached); // :299-304 (sent on the next Update flush) return; // :307 } // 3. Disconnect headers (:312-321). if ((packet.Header.Flags & PacketHeaderFlags.Disconnect) != 0) { Terminate(AceTerminationReason.PacketHeaderDisconnect); return; } if ((packet.Header.Flags & PacketHeaderFlags.NetErrorDisconnect) != 0) { Terminate(AceTerminationReason.ClientSentNetworkErrorDisconnect); return; } // 4. Timeout refresh (:329-331) — 60 s in-world horizon. TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks; // 5. Duplicate rejection (:342-347). Raw unsigned comparison — NOT // wrap-safe, exactly like ACE (a wrapped client sequence would be // mis-classified; modeled bug-for-bug). The ack-only exemption is // an EQUALITY check on the whole flags field, never HasFlag, and // only at seq == watermark exactly. if (packet.Header.Sequence <= _lastReceivedPacketSequence && packet.Header.Sequence != 0 && !(packet.Header.Flags == PacketHeaderFlags.AckSequence && packet.Header.Sequence == _lastReceivedPacketSequence)) { DuplicateDropCount++; return; } // 6. Out-of-order buffering (:351-363). NAK trigger fires only at // desiredSeq + 2 ≤ arrivedSeq, arrival-driven, with a 1 s rate // limit; a quiet link is never NAKed. uint desiredSeq = _lastReceivedPacketSequence + 1; if (packet.Header.Sequence > desiredSeq) { if (!_outOfOrderPackets.ContainsKey(packet.Header.Sequence)) _outOfOrderPackets.Add(packet.Header.Sequence, packet); bool rateLimitOpen = _lastNakTimestamp is null || _clock.GetTimestamp() - _lastNakTimestamp.Value > NakRateLimitTicks; if (desiredSeq + 2 <= packet.Header.Sequence && rateLimitOpen) DoRequestForRetransmission(packet.Header.Sequence); return; } // 7. Final processing (:367-378). HandleOrderedPacket(packet); CheckOutOfOrderPackets(); CheckOutOfOrderFragments(); } /// ClientPacket.VerifyCRC (ClientPacket.cs:138-163) over the crypto model. private bool VerifyCrc(ParsedPacket packet) { uint headerHash = packet.Header.CalculateHeaderHash32(); uint payloadHash = packet.Optional.CalculateHash32() + packet.FragmentHash; if ((packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) != 0) { // ClientPacket.cs:140-147 — extract the key, Search, then Consume. uint key = (packet.Header.Checksum - headerHash) ^ payloadHash; if (Crypto.Search(key)) { Crypto.ConsumeKey(key); return true; } return false; } // ClientPacket.cs:149-157 — additive cleartext checksum. return headerHash + payloadHash == packet.Header.Checksum; } /// /// NetworkManager.cs:50-79 — ConnectResponse routing. The double only /// supports the exact shape retail/acdream sends (flags == /// ConnectResponse alone, 8-byte cookie body). /// private void HandleConnectResponse(ParsedPacket packet) { if (packet.Header.Flags != PacketHeaderFlags.ConnectResponse) return; if (packet.Optional.RawBytes.Length < 8) return; ulong cookie = BinaryPrimitives.ReadUInt64LittleEndian(packet.Optional.RawBytes); if (cookie != _cookie) return; // NetworkManager.cs:60-66 — cookie mismatch: no session matches, ignored if (_handshakeComplete) return; // NetworkManager.cs:64-65 — session must still be in AuthConnectResponse _handshakeComplete = true; _sendResync = true; // NetworkManager.cs:78 — first TimeSync goes out immediately (:47-50) TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks; ConnectResponseAccepted?.Invoke(); } /// NetworkSession.HandleOrderedPacket (:435-477). private void HandleOrderedPacket(ParsedPacket packet) { // :440-443 + :650-661 — EchoRequest flags an EchoResponse onto the // next control-bundle flush. if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0) _pendingEchoClientTime = packet.Optional.EchoRequestClientTime; // :447-448 — consume the cumulative-ack VALUE: prune the S2C cache // strictly below it. if ((packet.Header.Flags & PacketHeaderFlags.AckSequence) != 0) AcknowledgeSequence(packet.Optional.AckSequence); // :450-457 — inbound TimeSync is read and ignored. // :463-468 — LoginRequest short-circuits to the auth handler and // RETURNS: no fragment processing and, crucially, no watermark // advance for LoginRequest packets. if ((packet.Header.Flags & PacketHeaderFlags.LoginRequest) != 0) { LoginRequestReceived?.Invoke(); return; } // :471-472 — fragments. foreach (MessageFragment fragment in packet.Fragments) ProcessFragment(fragment); // :474-476 — THE WATERMARK-HOLE RULE, pinned: the watermark advances // for every packet whose Sequence != 0 && Flags != AckSequence — an // EXACT equality check on the whole flags field. Any cleartext // non-ack control packet reusing a live sequence number advances the // watermark and permanently skips the real packet at that sequence. if (packet.Header.Sequence != 0 && packet.Header.Flags != PacketHeaderFlags.AckSequence) { _lastReceivedPacketSequence = packet.Header.Sequence; } } /// NetworkSession.ProcessFragment (:483-544). private void ProcessFragment(MessageFragment fragment) { byte[]? message = null; if (fragment.Header.Count != 1) { // :489-518 — split message, buffered by fragment sequence. if (!_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer)) { buffer = new PartialC2SMessage(fragment.Header.Count); _partialFragments.Add(fragment.Header.Sequence, buffer); } buffer.Add(fragment.Header.Index, fragment.Payload); if (buffer.Complete) { message = buffer.Assemble(); _partialFragments.Remove(fragment.Header.Sequence); } } else if (fragment.Payload.Length >= 4) { // :520-527 — unsplit; ClientMessage needs ≥ 4 bytes. message = fragment.Payload; } if (message is null) return; // :532-543 — THE C2S FRAGMENT GATE, pinned: a completed message // dispatches only when its fragment sequence is exactly // lastReceivedFragmentSequence + 1; anything else parks in // outOfOrderFragments (including OLD fragment sequences, which park // forever — ACE bug-for-bug). if (fragment.Header.Sequence == _lastReceivedFragmentSequence + 1) HandleFragment(message); else _outOfOrderFragments.TryAdd(fragment.Header.Sequence, message); } /// NetworkSession.HandleFragment (:550-554). private void HandleFragment(byte[] message) { _dispatchedMessages.Add(message); MessageDispatched?.Invoke(message); _lastReceivedFragmentSequence++; } /// NetworkSession.CheckOutOfOrderPackets (:559-566). private void CheckOutOfOrderPackets() { while (_outOfOrderPackets.Remove(_lastReceivedPacketSequence + 1, out ParsedPacket? packet)) HandleOrderedPacket(packet); } /// NetworkSession.CheckOutOfOrderFragments (:571-578). private void CheckOutOfOrderFragments() { while (_outOfOrderFragments.Remove(_lastReceivedFragmentSequence + 1, out byte[]? message)) HandleFragment(message); } /// NetworkSession.AcknowledgeSequence (:663-673) — prune strictly-older /// cached S2C packets. Raw uint compare (`x < sequence`), NOT wrap-safe: /// modeled exactly as ACE does it. private void AcknowledgeSequence(uint sequence) { List? removal = null; foreach (uint key in _cachedPackets.Keys) { if (key < sequence) (removal ??= new List()).Add(key); } if (removal is null) return; foreach (uint key in removal) _cachedPackets.Remove(key); } /// NetworkSession.DoRequestForRetransmission (:387-426). private void DoRequestForRetransmission(uint rcvdSeq) { uint desiredSeq = _lastReceivedPacketSequence + 1; // :389 var needSeq = new List { desiredSeq }; // :390-391 uint bottom = desiredSeq + 1; // :392 // :393-397 — gap beyond the 256-key crypto search window is fatal. // Note this check lives INSIDE the rate-limited call, exactly like // ACE: a huge gap arriving while the 1 s limiter is closed does NOT // terminate until the next NAK-eligible arrival. if (rcvdSeq < bottom || rcvdSeq - bottom > AceCryptoModel.MaximumEffortLevel) { Terminate(AceTerminationReason.AbnormalSequenceReceived); return; } uint seqIdCount = 1; // :398-410 — cap at 115 ids, skipping buffered arrivals for (uint a = bottom; a < rcvdSeq; a++) { if (_outOfOrderPackets.ContainsKey(a)) continue; needSeq.Add(a); seqIdCount++; if (seqIdCount >= MaxNumNakSeqIds) break; } // :412-420 — u32 count + ids, flags RequestRetransmit, CLEARTEXT // (ServerPacket default — no EncryptedChecksum), queued for the next // FlushPackets pass. byte[] body = new byte[4 + needSeq.Count * 4]; BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)needSeq.Count); for (int i = 0; i < needSeq.Count; i++) { BinaryPrimitives.WriteUInt32LittleEndian( body.AsSpan(4 + i * 4), needSeq[i]); } _flushQueue.Enqueue(new OutboundDraft( PacketHeaderFlags.RequestRetransmit, body, OptionalLength: body.Length)); _lastNakTimestamp = _clock.GetTimestamp(); // :422 } /// NetworkSession.Retransmit (:675-708) — serve a NAKed id from the cache. private bool TryRetransmit(uint sequence) { if (!_cachedPackets.TryGetValue(sequence, out CachedS2CPacket? cached)) return false; // :707 — caller collects the id for RejectRetransmit // :681-682 — OR the Retransmission flag INTO THE CACHE ENTRY (it // sticks for any later retransmit of the same packet). cached.Flags |= PacketHeaderFlags.Retransmission; // :684 SendPacketRaw → ServerPacket.CreateReadyToSendPacket // (ServerPacket.cs:46-72): the header hash is recomputed with the new // flags, the checksum reuses the ORIGINAL IssacXor — NO new keystream // word is drawn — and Header.Time keeps its original flush value. // The retransmit bypasses FlushPackets: it is emitted immediately, // before any queued RejectRetransmit. Emit(cached.Sequence, cached.Flags, cached.Time, cached.Body, cached.OptionalLength, cached.IsaacXor); RetransmitsServed++; return true; } /// NetworkSession.cs:299-304 + PacketRejectRetransmit.cs:7-17 — /// u32 count + uncached ids, cleartext, queued (flows through FlushPackets, /// so like ACE it consumes a sequence number and can even be cached). private void EnqueueRejectRetransmit(List uncached) { byte[] body = new byte[4 + uncached.Count * 4]; BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)uncached.Count); for (int i = 0; i < uncached.Count; i++) { BinaryPrimitives.WriteUInt32LittleEndian( body.AsSpan(4 + i * 4), uncached[i]); } _flushQueue.Enqueue(new OutboundDraft( PacketHeaderFlags.RejectRetransmit, body, OptionalLength: body.Length)); } // ===================================================================== // Send side — NetworkSession.Update (:182-249) + FlushPackets (:710-735) // + SendPacket (:737-752), driven by the virtual clock. // ===================================================================== /// /// One server pump: timeout check, cache prune, control bundle /// (ack/TimeSync/EchoResponse), message bundles, then FlushPackets. /// ACE runs this from the world tick; the double runs it whenever the /// harness pumps. /// public void Update() { if (IsTerminated) return; // WorldManager's TimeoutTick check (NetworkSession.cs:88). Every ACE // transport death is silence — no disconnect packet is ever sent. if (_clock.GetTimestamp() > TimeoutDeadlineTimestamp) { Terminate(AceTerminationReason.NetworkTimeout); return; } // :187-188 — prune the S2C cache every 5 s. if (_lastPruneTimestamp is null || _clock.GetTimestamp() - _lastPruneTimestamp.Value > CachePruneIntervalTicks) { PruneCachedPackets(); } if (_handshakeComplete) { BuildControlDraft(); FlushMessageBundles(); } // FlushPackets (:710-735) — drains receive-time NAK/Reject enqueues // first (FIFO), then this pump's bundles. while (_flushQueue.TryDequeue(out OutboundDraft draft)) FlushOne(draft); } /// /// Server-side game-message send. Flushed by the next /// as its own BlobFragments|EncryptedChecksum packet (EnqueueSend sets /// EncryptedChecksum, NetworkSession.cs:129). /// public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group) => _pendingMessages.Add((gameMessageBody, group)); /// /// AuthenticationHandler → PacketOutboundConnectRequest: 32-byte /// cleartext section (serverTime, cookie, clientId, serverSeed, /// clientSeed, padding), queued through the normal packet flush — its /// sequence is 0, the first NextValue of the unprimed UIntSequence. /// public void SendConnectRequest() { byte[] body = new byte[32]; BinaryPrimitives.WriteInt64LittleEndian( body, BitConverter.DoubleToInt64Bits(_clock.Seconds)); BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), _cookie); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), _clientId); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), _serverSeed); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), _clientSeed); // bytes 28..31: trailing padding uint, zero. _flushQueue.Enqueue(new OutboundDraft( PacketHeaderFlags.ConnectRequest, body, OptionalLength: body.Length)); } /// /// The InvalidQueue control bundle (:203-216) written per /// WriteOptionalHeaders (:921-948): ack value, then TimeSync, then /// EchoResponse. A pure ack is CLEARTEXT with flags exactly AckSequence; /// TimeSync and EchoResponse force EncryptedChecksum (:207, :659), so a /// coalesced ack+TimeSync packet is encrypted and sequenced — exactly /// ACE's behavior when both timers fire in one pump. /// private void BuildControlDraft() { bool resyncDue = _sendResync && (_nextResyncTimestamp is null || _clock.GetTimestamp() > _nextResyncTimestamp.Value); // :203 (+ :47-50 immediate first send) bool ackDue = _clock.GetTimestamp() > _nextAckTimestamp; // :211 (sendAck is always true, :54) bool echoDue = _pendingEchoClientTime is not null; // :941 (ClientTime != -1) if (!resyncDue && !ackDue && !echoDue) return; var flags = PacketHeaderFlags.None; var writer = new PacketWriter(24); if (ackDue) { flags |= PacketHeaderFlags.AckSequence; // :925-931 writer.WriteUInt32(_lastReceivedPacketSequence); _nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks; // :215 } if (resyncDue) { flags |= PacketHeaderFlags.TimeSync | PacketHeaderFlags.EncryptedChecksum; // :933-938 + :207 Span value = stackalloc byte[8]; BinaryPrimitives.WriteInt64LittleEndian( value, BitConverter.DoubleToInt64Bits(_clock.Seconds)); writer.WriteBytes(value); _nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks; // :208 } if (echoDue) { flags |= PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum; // :941-948 + :659 writer.WriteFloat(_pendingEchoClientTime!.Value); writer.WriteFloat((float)_clock.Seconds - _pendingEchoClientTime.Value); _pendingEchoClientTime = null; } byte[] body = writer.ToArray(); _flushQueue.Enqueue(new OutboundDraft(flags, body, OptionalLength: body.Length)); } /// /// SendBundle for queued game messages: groups flush in ascending group /// order (:190-194), fragment sequences are assigned at flush time /// (:821) starting from 0 (SessionConnectionData.FragmentSequence), and /// the fragment Id is the constant 0x80000000 (ACE MessageFragment.cs:94). /// One packet per message — see the class-doc simplification note. /// private void FlushMessageBundles() { if (_pendingMessages.Count == 0) return; foreach ((byte[] body, GameMessageGroup group) in _pendingMessages.OrderBy(m => (int)m.Group)) // OrderBy is stable → FIFO within a group { MessageFragment fragment = GameMessageFragment.BuildSingleFragment( _s2cFragmentSequence++, group, body); byte[] fragmentBytes = GameMessageFragment.Serialize(fragment); _flushQueue.Enqueue(new OutboundDraft( PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, fragmentBytes, OptionalLength: 0)); } _pendingMessages.Clear(); } /// FlushPackets, per packet (:710-735) + SendPacket (:737-752). private void FlushOne(OutboundDraft draft) { bool encrypted = (draft.Flags & PacketHeaderFlags.EncryptedChecksum) != 0; // :716-717 — the first encrypted flush re-primes the sequence to // CurrentValue = 1, so the first encrypted S2C packet is sequence 2. if (encrypted && _packetSequence == 0) _packetSequence = 1; bool isNak = (draft.Flags & PacketHeaderFlags.RequestRetransmit) != 0; // :719 // :722-725 — ack-only (EXACT flags) and NAK packets reuse the current // sequence without incrementing; everything else takes NextValue. uint sequence = draft.Flags == PacketHeaderFlags.AckSequence || isNak ? _packetSequence : NextPacketSequence(); // :728 — Header.Time = (ushort)PortalYearTicks (whole seconds). ushort time = (ushort)(long)_clock.Seconds; // SendPacket (:743-748) — one S2C keystream word per encrypted // packet; cleartext packets use xor 0 (ServerPacket.cs:70 makes the // checksum additive in that case). uint isaacXor = encrypted ? _s2cKeystream.Next() : 0u; // :730-731 — cache sequenced packets ≥ 2 that are not NAKs. TryAdd // semantics: an ack reusing a live sequence does not overwrite. if (sequence >= 2u && !isNak) { _cachedPackets.TryAdd(sequence, new CachedS2CPacket { Sequence = sequence, Flags = draft.Flags, Time = time, Body = draft.Body, OptionalLength = draft.OptionalLength, IsaacXor = isaacXor, }); } Emit(sequence, draft.Flags, time, draft.Body, draft.OptionalLength, isaacXor); } /// UIntSequence.NextValue (UIntSequence.cs:30-41): wrap max → 0. private uint NextPacketSequence() { _packetSequence = _packetSequence == uint.MaxValue ? 0u : _packetSequence + 1u; return _packetSequence; } /// ServerPacket.CreateReadyToSendPacket (ServerPacket.cs:46-72). private void Emit( uint sequence, PacketHeaderFlags flags, ushort time, byte[] body, int optionalLength, uint isaacXor) { var header = new PacketHeader { Sequence = sequence, Flags = flags, Id = _serverId, // :726 Iteration = 1, // :727 Time = time, DataSize = checked((ushort)body.Length), }; uint payloadHash = ComputePayloadHash(body, flags, optionalLength); uint headerHash = header.CalculateHeaderHash32(); header.Checksum = headerHash + (payloadHash ^ isaacXor); // ServerPacket.cs:70 byte[] datagram = new byte[PacketHeader.Size + body.Length]; header.Pack(datagram); body.CopyTo(datagram.AsSpan(PacketHeader.Size)); _sentDatagrams.Add(datagram); _pendingOutbound.Enqueue(datagram); } /// ServerPacket.cs:48-62 — Hash32(data section) + Σ fragment hashes. private static uint ComputePayloadHash( ReadOnlySpan body, PacketHeaderFlags flags, int optionalLength) { uint hash = Hash32.Calculate(body.Slice(0, optionalLength)); if ((flags & PacketHeaderFlags.BlobFragments) == 0) return hash; ReadOnlySpan remaining = body.Slice(optionalLength); while (!remaining.IsEmpty) { (MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining); if (fragment is null) throw new InvalidOperationException("the model built a malformed fragment"); hash += PacketCodec.CalculateFragmentHash32(fragment.Value); remaining = remaining.Slice(consumed); } return hash; } /// NetworkSession.PruneCachedPackets (:251-262) — 120 s retention /// with ACE's ushort-wrap guard expression, verbatim. private void PruneCachedPackets() { _lastPruneTimestamp = _clock.GetTimestamp(); // :253 ushort currentTime = (ushort)(long)_clock.Seconds; // :255 List? removal = null; foreach (CachedS2CPacket packet in _cachedPackets.Values) { // :258 — wrap guard: `(currentTime >= x.Time ? currentTime : currentTime + ushort.MaxValue) - x.Time > 120` if ((currentTime >= packet.Time ? currentTime : currentTime + ushort.MaxValue) - packet.Time > CachedPacketRetentionSeconds) { (removal ??= new List()).Add(packet.Sequence); } } if (removal is null) return; foreach (uint sequence in removal) _cachedPackets.Remove(sequence); } private void Terminate(AceTerminationReason reason) { IsTerminated = true; TerminationReason = reason; } // ===================================================================== // Parsing — ClientPacket.Unpack (ClientPacket.cs:22-76) equivalent over // acdream's owned wire types. Malformed datagrams are dropped silently. // ===================================================================== private static bool TryParse(ReadOnlySpan datagram, out ParsedPacket packet) { packet = null!; if (datagram.Length < PacketHeader.Size) return false; // ClientPacket.cs:26-27 PacketHeader header = PacketHeader.Unpack(datagram); if (header.DataSize > datagram.Length - PacketHeader.Size) return false; // ClientPacket.cs:31-32 ReadOnlySpan body = datagram.Slice(PacketHeader.Size, header.DataSize); var optional = new PacketHeaderOptional(); int optionalConsumed = optional.Parse(body, header.Flags); if (optionalConsumed < 0) return false; // ClientPacket.cs:38-39 (HeaderOptional.IsValid) var fragments = new List(); uint fragmentHash = 0; if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0) { // ClientPacket.ReadFragments (:54-76) + fragmentChecksum (:84-101). ReadOnlySpan remaining = body.Slice(optionalConsumed); while (!remaining.IsEmpty) { (MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining); if (fragment is null) return false; fragments.Add(fragment.Value); fragmentHash += PacketCodec.CalculateFragmentHash32(fragment.Value); remaining = remaining.Slice(consumed); } } packet = new ParsedPacket(header, optional, fragments, fragmentHash); return true; } /// /// A parsed, CRC-verifiable C2S packet. Buffered out-of-order packets are /// stored in THIS form — ACE never re-verifies a buffered packet's CRC /// (the key was consumed on first arrival). /// private sealed record ParsedPacket( PacketHeader Header, PacketHeaderOptional Optional, List Fragments, uint FragmentHash); private readonly record struct OutboundDraft( PacketHeaderFlags Flags, byte[] Body, int OptionalLength); /// The cached ServerPacket surrogate — see FlushOne/TryRetransmit. private sealed class CachedS2CPacket { public uint Sequence; public PacketHeaderFlags Flags; public ushort Time; public byte[] Body = Array.Empty(); public int OptionalLength; public uint IsaacXor; } /// ACE MessageBuffer surrogate (NetworkSession.cs:495-518). private sealed class PartialC2SMessage { private readonly byte[]?[] _parts; private int _received; public PartialC2SMessage(int totalFragments) => _parts = new byte[totalFragments][]; public bool Complete => _received == _parts.Length; public void Add(int index, byte[] payload) { if (_parts[index] is not null) return; // duplicate index — idempotent _parts[index] = payload; _received++; } public byte[] Assemble() { int total = 0; foreach (byte[]? part in _parts) total += part!.Length; byte[] message = new byte[total]; int offset = 0; foreach (byte[]? part in _parts) { byte[] bytes = part!; bytes.CopyTo(message.AsSpan(offset)); offset += bytes.Length; } return message; } } }