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, Session.cs:140-144) expired — every ACE transport death is silence. NetworkTimeout, } /// /// ACE's SessionState (Network/Enum/SessionState.cs), reduced to the /// three states a transport-level conversation can reach. The gate that reads /// it is Session.CheckState (Session.cs:93-105). /// /// /// Not modeled: WorldConnected (set by CharacterHandler.cs:260 — it /// gates gameplay handlers, never the transport pipeline) and /// TerminationStarted (Session.cs:128; only reachable with /// PendingTermination already set, and the only code reading it, /// Session.cs:136-137, is unreachable while it is). /// /// internal enum AceSessionState { /// Pre-login. CheckState drops Ack/TimeSync/Echo/Flow here. AuthLoginRequest, /// ConnectRequest sent, waiting for the ConnectResponse (AuthenticationHandler.cs:232). AuthConnectResponse, /// Handshake complete (NetworkManager.cs:77). AuthConnected, } /// ACE's SessionTerminationPhase (Network/Enum/SessionTerminationPhase.cs). /// WorldManagerWorkCompleted is a post-drop bookkeeping marker /// (NetworkManager.cs:369) with no transport-visible effect and is not /// modeled. internal enum AceTerminationPhase { /// Session.cs:126-131 — the ~2 s window in which inbound and outbound still run. Initialized, /// Session.cs:130-131 — the window elapsed; WorldManager may now DropSession. SessionWorkCompleted, } /// /// Transport-free model of ACE's per-connection Session + /// 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. /// /// /// /// Not an independent wire oracle. C2S CRC verification reuses /// acdream's own to parse and hash the /// optional-header section, so the double is blind to any bug SHARED by our /// encoder and our parser: an optional section we write wrong and read back /// wrong still checksums correctly here, while real ACE would drop it. Two /// asymmetries against ACE's PacketHeaderOptional.Unpack are known and /// deliberate: /// /// ACE has NO inbound ConnectRequest branch (PacketHeaderOptional.cs:30-124 /// goes straight from AckSequence to LoginRequest); acdream decodes a /// 32-byte section (PacketHeaderOptional.cs:139-150) because we are the /// client. A C2S packet carrying that flag would hash differently on /// real ACE. /// ACE hashes-but-does-not-advance the reader for LoginRequest /// (PacketHeaderOptional.cs:78), WorldLoginRequest (:86) and /// ConnectResponse (:94); acdream advances past WorldLoginRequest and /// ConnectResponse (PacketHeaderOptional.cs:130-133, :152-155). The /// hashed bytes match; only the fragment-loop start offset differs, and /// only for packets carrying those flags plus fragments (none exist). /// /// The independent oracle for optional-header wire layout stays the live-ACE /// connected gate, not this double. /// /// /// /// 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 Session.TickOutbound, Session.cs:140-144). /// 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. /// Ack/TimeSync/EchoResponse/message-bundle 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). Queued raw packets (ConnectRequest, NAK, /// RejectRetransmit) flush regardless, like ACE's FlushPackets. /// VerifyEcho's speed-hack detector (NetworkSession.cs:593-647) /// is not modeled: it can log off a player but never terminates the /// transport session. /// The inbound retransmit/reject list cap of 1024 ids that acdream's /// parser enforces (PacketHeaderOptional.cs:82, :96) does not exist in /// ACE (PacketHeaderOptional.cs:35-60). It is unreachable rather than /// wrong: ACE reads into a 1024-byte buffer (ConnectionListener.cs:26, /// ClientPacket.cs:14), so the widest list a C2S datagram can carry is /// (1024 − 20 header − 4 count) / 4 = 250 ids. /// /// /// internal sealed class AceSessionModel { // ---- ACE constants, cited ---- /// NetworkSession.cs:381 — max NAK ids per RequestRetransmit. private const int MaxNumNakSeqIds = 115; /// ServerPacket.cs:11 — the S2C body budget after the 20-byte header. private const int MaxPacketSize = 464; /// GameMessageGroup.cs:18 — the bundle array length. private const int QueueMax = 0x0C; /// 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; /// SessionTerminationDetails.cs:12 — TerminationEndTicks = start + 2 s. private static readonly long TerminationWindowTicks = TimeSpan.FromSeconds(2).Ticks; // ---- 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; // ---- 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.cs:36 — 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; /// NetworkSession.cs:38-39 — one NetworkBundle per GameMessageGroup. private readonly PendingBundle[] _bundles = new PendingBundle[QueueMax]; /// NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update. private readonly Queue _flushQueue = new(); // ---- termination state ---- private long _terminationEndTimestamp; // ---- 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); for (int i = 0; i < _bundles.Length; i++) _bundles[i] = new PendingBundle(); // NetworkSession.cs:105-109 // 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 ---- /// Session.State (Session.cs:36) — the value CheckState reads. public AceSessionState State { get; private set; } = AceSessionState.AuthLoginRequest; 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; /// /// True once Session.Terminate has armed PendingTermination /// (Session.cs:281-298). Inbound and outbound keep running for the ~2 s /// termination window — see for the point of no /// return. /// public bool IsTerminated => TerminationPhase is not null; /// Session.PendingTermination.TerminationStatus (Session.cs:56, :126-131). public AceTerminationPhase? TerminationPhase { get; private set; } /// /// NetworkSession.isReleased (:952, :958-974) — set by Session.DropSession /// (:300-334) once the termination window closed. From here every inbound /// packet and every pump is ignored. /// public bool IsReleased { get; private set; } public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None; /// VirtualClock timestamp at or 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; } /// Packets dropped by the Session.CheckState gate (Session.cs:93-110) — before CRC. public int StateDropCount { 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 — Session.ProcessPacket (Session.cs:107-113) then // NetworkSession.ProcessPacket (:269-379), in ACE's exact order. // ===================================================================== public void Receive(ReadOnlySpan datagram) { if (IsReleased) return; // isReleased guard (:271-272) if (!TryParse(datagram, out ParsedPacket packet)) return; // ClientPacket.Unpack failure — ConnectionListener discards silently // 0. Session.CheckState (Session.cs:93-110) runs BEFORE // NetworkSession.ProcessPacket — so before VerifyCRC. A packet // dropped here costs ZERO keystream: ACE never looked at its // checksum. if (!CheckState(packet.Header)) { StateDropCount++; return; } // 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). The state requirement is identical // on both paths (NetworkManager.cs:64 vs Session.cs:98-99), so running // CheckState first is faithful for the double's single-listener shape. 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(); } /// /// Session.CheckState (Session.cs:93-105). Three drops, all of them /// BEFORE NetworkSession.ProcessPacket and therefore before /// ClientPacket.VerifyCRC — no keystream word is consumed, no /// watermark moves, no CRC counter ticks. Note ACE's /// PacketHeader.HasFlag is ANY-of, not all-of /// (PacketHeader.cs:70), so the fourth line drops a packet carrying ANY /// of the four control flags. /// private bool CheckState(PacketHeader header) { // :95-96 if ((header.Flags & PacketHeaderFlags.LoginRequest) != 0 && State != AceSessionState.AuthLoginRequest) { return false; } // :98-99 (and the identical requirement on NetworkManager's port+1 // path, NetworkManager.cs:60-66). if ((header.Flags & PacketHeaderFlags.ConnectResponse) != 0 && State != AceSessionState.AuthConnectResponse) { return false; } // :101-102 const PacketHeaderFlags controlFlags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.TimeSync | PacketHeaderFlags.EchoRequest | PacketHeaderFlags.Flow; if ((header.Flags & controlFlags) != 0 && State == AceSessionState.AuthLoginRequest) { return false; } return true; } /// 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). The /// State == AuthConnectResponse half of NetworkManager's session /// lookup (:64) is enforced by above. /// 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 State = AceSessionState.AuthConnected; // NetworkManager.cs:77 _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 // InvalidQueue bundle (and forces its checksum encrypted). if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0) FlagEcho(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.FlagEcho (:650-661). private void FlagEcho(float clientTime) { PendingBundle bundle = _bundles[(int)GameMessageGroup.InvalidQueue]; bundle.ClientTime = clientTime; bundle.EncryptedChecksum = true; } /// NetworkSession.ProcessFragment (:483-544). private void ProcessFragment(MessageFragment fragment) { byte[]? message = null; if (fragment.Header.Count != 1) { // ACE's two literal branches, kept separate because only ONE of // them checks Complete: if (_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer)) { // :495-507 — existing buffer: add, then check Complete. buffer.AddFragment(fragment.Header.Index, fragment.Payload); if (buffer.Complete) { // :504-506 — TryGetMessage may return null (assembled // stream under 4 bytes, MessageBuffer.cs:49-50) but the // buffer is removed EITHER WAY. message = buffer.TryGetMessage(); _partialFragments.Remove(fragment.Header.Sequence); } } else { // :509-518 — new buffer: construct + AddFragment + TryAdd, // WITHOUT checking Complete. A fragment whose Count can // never be reached from here (Count == 0: AddFragment // refuses to add to an already-"Complete" buffer) parks a // dead buffer in partialFragments forever — ACE // bug-for-bug. var newBuffer = new PartialC2SMessage(fragment.Header.Count); newBuffer.AddFragment(fragment.Header.Index, fragment.Payload); _partialFragments.TryAdd(fragment.Header.Sequence, newBuffer); } } 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). A message dropped for being under // 4 bytes never reaches here, so it never advances the gate: every // later message stalls behind the hole it leaves. 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 — Session.TickOutbound (Session.cs:119-176) → // NetworkSession.Update (:182-249) + SendBundle (:808-919) + // FlushPackets (:710-735) + SendPacket (:737-752), driven by the virtual // clock. // ===================================================================== /// /// One server pump: the termination window, the timeout check, then the /// network update (cache prune, bundles, flush). ACE runs this from the /// world tick; the double runs it whenever the harness pumps. /// public void Update() { if (IsReleased) return; // NetworkSession.cs:184-185 // Session.cs:124-134 — the two-phase termination window. Everything // keeps running for ~2 s (SessionTerminationDetails.cs:12) so boot // messages and queued packets can still reach the client. if (TerminationPhase is not null) { if (TerminationPhase == AceTerminationPhase.Initialized) { RunNetworkUpdate(); // :129 — "boot messages may need sending" if (_clock.GetTimestamp() > _terminationEndTimestamp) TerminationPhase = AceTerminationPhase.SessionWorkCompleted; // :130-131 } // NetworkManager.cs:366-369 drains completed sessions through // Session.DropSession (:300-334), whose last act is // NetworkSession.ReleaseResources (:333 → :958-974). The double // folds that drain into the same pump. if (TerminationPhase == AceTerminationPhase.SessionWorkCompleted) Release(); return; // :133 } // Session.cs:140-144 — `DateTime.UtcNow.Ticks >= Network.TimeoutTick`. // The boundary is inclusive: at exactly the deadline the session dies. // Every ACE transport death is silence — no disconnect packet is sent. if (_clock.GetTimestamp() >= TimeoutDeadlineTimestamp) { Terminate(AceTerminationReason.NetworkTimeout); return; } RunNetworkUpdate(); // :147 } /// NetworkSession.Update (:182-249). private void RunNetworkUpdate() { // :187-188 — prune the S2C cache every 5 s. if (_lastPruneTimestamp is null || _clock.GetTimestamp() - _lastPruneTimestamp.Value > CachePruneIntervalTicks) { PruneCachedPackets(); } if (State == AceSessionState.AuthConnected) { // :190-246 — walk every bundle in ascending group order, arm the // InvalidQueue control flags, swap out and send whatever needs // sending. for (int i = 0; i < QueueMax; i++) { var group = (GameMessageGroup)i; PendingBundle bundle = _bundles[i]; if (group == GameMessageGroup.InvalidQueue) { // :203-209 — TimeSync forces an encrypted checksum. if (_sendResync && !bundle.TimeSync && (_nextResyncTimestamp is null || _clock.GetTimestamp() > _nextResyncTimestamp.Value)) { bundle.TimeSync = true; bundle.EncryptedChecksum = true; _nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks; } // :211-216 — sendAck is always true (:54); a pure ack stays // CLEARTEXT. if (!bundle.SendAck && _clock.GetTimestamp() > _nextAckTimestamp) { bundle.SendAck = true; _nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks; } } if (!bundle.NeedsSending) continue; _bundles[i] = new PendingBundle(); // :223 / :233 — swap SendBundle(bundle, group); // :243 } } // 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 — NetworkSession.EnqueueSend (:117-134): /// the message joins its group's bundle and forces that bundle's checksum /// encrypted (:129). It leaves on the next , coalesced /// with whatever else is in the same bundle. /// public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group) { PendingBundle bundle = _bundles[(int)group]; bundle.EncryptedChecksum = true; bundle.Enqueue(gameMessageBody); } /// /// AuthenticationHandler → PacketOutboundConnectRequest /// (AuthenticationHandler.cs:118-127): 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. The same callback moves the /// session to AuthConnectResponse (AuthenticationHandler.cs:232), which is /// what closes the LoginRequest half of . /// 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)); State = AceSessionState.AuthConnectResponse; // AuthenticationHandler.cs:232 } /// /// NetworkSession.SendBundle (:808-919) — turn one bundle into one OR MORE /// packets: as many same-bundle fragments as fit in the 464-byte body /// budget travel together (one sequence, one keystream word), and a /// message whose remaining data fills a packet is split across packets /// with Count > 1 fragments. /// /// /// Fragment sequences are assigned HERE (:821), from /// SessionConnectionData.FragmentSequence (starting at 0), in bundle /// order; the fragment Id is the constant 0x80000000 (MessageFragment.cs:94). /// /// private void SendBundle(PendingBundle bundle, GameMessageGroup group) { bool writeOptionalHeaders = true; // :817-823 — pull every message out and wrap it in a MessageFragment. var fragments = new List(); while (bundle.HasMoreMessages) fragments.Add(new OutboundMessage(bundle.Dequeue(), _s2cFragmentSequence++, group)); // :828 — loop while we have fragments (or still owe optional headers). while (fragments.Count > 0 || writeOptionalHeaders) { var flags = PacketHeaderFlags.None; var packetFragments = new List(); byte[] optionalBytes = Array.Empty(); if (fragments.Count > 0) flags |= PacketHeaderFlags.BlobFragments; // :833-834 if (bundle.EncryptedChecksum) flags |= PacketHeaderFlags.EncryptedChecksum; // :836-837 int availableSpace = MaxPacketSize; // :839 OutboundMessage? firstMessage = fragments.Count > 0 ? fragments[0] : null; // :842 if (firstMessage is not null) { if (firstMessage.DataRemaining >= availableSpace) { // :846-854 — a large message fills the whole packet alone. MessageFragment spf = firstMessage.GetNextFragment(); packetFragments.Add(spf); availableSpace -= spf.WireSize; if (firstMessage.DataRemaining <= 0) fragments.Remove(firstMessage); } else { // :856-903 — optional headers first, then pack in as many // small messages (and large-message tails) as fit. if (writeOptionalHeaders) { writeOptionalHeaders = false; optionalBytes = WriteOptionalHeaders(bundle, ref flags); availableSpace -= optionalBytes.Length; } var removeList = new List(); foreach (OutboundMessage fragment in fragments) { bool fragmentSkipped = false; if (!fragment.TailSent && availableSpace >= fragment.TailSize) { // :874-880 — the tail of an already-split message. MessageFragment spf = fragment.GetTailFragment(); packetFragments.Add(spf); availableSpace -= spf.WireSize; } else if (availableSpace >= fragment.NextSize) { // :882-888 — a whole small message. MessageFragment spf = fragment.GetNextFragment(); packetFragments.Add(spf); availableSpace -= spf.WireSize; } else { fragmentSkipped = true; } if (fragment.DataRemaining <= 0) removeList.Add(fragment); // :892-894 // :896-898 — UIQueue must stay strictly ordered. if (fragmentSkipped && group == GameMessageGroup.UIQueue) break; } fragments.RemoveAll(removeList.Contains); // :902 } } else if (writeOptionalHeaders) { // :906-916 — no messages: a control-only packet. writeOptionalHeaders = false; optionalBytes = WriteOptionalHeaders(bundle, ref flags); } _flushQueue.Enqueue(BuildDraft(flags, optionalBytes, packetFragments)); // :917 } } /// /// NetworkSession.WriteOptionalHeaders (:921-948) — ack value, then /// TimeSync, then EchoResponse, in that order. The EncryptedChecksum /// forcing for TimeSync/EchoResponse lives on the BUNDLE (:207, :659), not /// here, so a pure ack stays cleartext. /// private byte[] WriteOptionalHeaders(PendingBundle bundle, ref PacketHeaderFlags flags) { var writer = new PacketWriter(24); if (bundle.SendAck) // :925-931 { flags |= PacketHeaderFlags.AckSequence; writer.WriteUInt32(_lastReceivedPacketSequence); } if (bundle.TimeSync) // :933-939 { flags |= PacketHeaderFlags.TimeSync; Span value = stackalloc byte[8]; BinaryPrimitives.WriteInt64LittleEndian( value, BitConverter.DoubleToInt64Bits(_clock.Seconds)); writer.WriteBytes(value); } if (bundle.ClientTime != -1f) // :941-948 { flags |= PacketHeaderFlags.EchoResponse; writer.WriteFloat(bundle.ClientTime); writer.WriteFloat((float)_clock.Seconds - bundle.ClientTime); } return writer.ToArray(); } /// Concatenate the optional section and the packet's fragments into /// one flush draft (ServerPacket.Data + ServerPacket.Fragments). private static OutboundDraft BuildDraft( PacketHeaderFlags flags, byte[] optionalBytes, List fragments) { int total = optionalBytes.Length; foreach (MessageFragment fragment in fragments) total += fragment.WireSize; byte[] body = new byte[total]; optionalBytes.CopyTo(body.AsSpan()); int offset = optionalBytes.Length; foreach (MessageFragment fragment in fragments) { fragment.Header.Pack(body.AsSpan(offset)); fragment.Payload.CopyTo(body.AsSpan(offset + MessageFragmentHeader.Size)); offset += fragment.WireSize; } return new OutboundDraft(flags, body, optionalBytes.Length); } /// 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) { if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed)) throw new InvalidOperationException("the model built a malformed fragment"); hash += PacketCodec.CalculateFragmentHash32(fragment); remaining = remaining.Slice(consumed); } return hash; } /// NetworkSession.PruneCachedPackets (:251-262) — 120 s retention /// with ACE's ushort-wrap guard expression, verbatim. Strictly greater /// than 120: an entry exactly 120 s old survives. 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); } /// /// Session.Terminate (Session.cs:281-298) — arms PendingTermination with a /// 2 s window (SessionTerminationDetails.cs:11-12). It does NOT stop the /// session: inbound keeps processing and outbound keeps flushing until /// . A second Terminate overwrites the details, exactly /// like ACE (:293). /// private void Terminate(AceTerminationReason reason) { TerminationReason = reason; TerminationPhase = AceTerminationPhase.Initialized; _terminationEndTimestamp = _clock.GetTimestamp() + TerminationWindowTicks; } /// NetworkSession.ReleaseResources (:958-974), reached through /// Session.DropSession (:300-334). private void Release() { IsReleased = true; _outOfOrderPackets.Clear(); _partialFragments.Clear(); _outOfOrderFragments.Clear(); _cachedPackets.Clear(); _flushQueue.Clear(); for (int i = 0; i < _bundles.Length; i++) _bundles[i] = new PendingBundle(); // :962-963 (ACE nulls them) } // ===================================================================== // 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) { if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed)) return false; fragments.Add(fragment); fragmentHash += PacketCodec.CalculateFragmentHash32(fragment); remaining = remaining.Slice(consumed); } } packet = new ParsedPacket(header, optional, fragments, fragmentHash); return true; } /// /// ClientPacketFragment.Unpack (ClientPacketFragment.cs:10-23) — ACE's /// COMPLETE inbound fragment validation: a 16-byte header, then /// Size − 16 >= 0 (:14-15) and Size <= 464 (:17-18). /// Deliberately looser than acdream's production /// MessageFragment.TryParseLayout, which additionally rejects /// Count == 0 and Index >= Count: the double's C2S parse /// path exists to characterize ACE, so it must accept everything ACE /// accepts. ACE's BinaryReader.ReadBytes also tolerates a short /// read at the end of the body (:20), producing a truncated payload /// instead of a parse failure — modeled here by clamping. /// private static bool TryParseClientFragment( ReadOnlySpan source, out MessageFragment fragment, out int consumed) { fragment = default; consumed = 0; // A header that cannot be read throws inside ACE's reader and is // caught as "corrupt packet" (ClientPacket.cs:67-71). if (source.Length < MessageFragmentHeader.Size) return false; MessageFragmentHeader header = MessageFragmentHeader.Unpack(source); if (header.TotalSize < MessageFragmentHeader.Size) return false; // ClientPacketFragment.cs:14-15 if (header.TotalSize > MessageFragmentHeader.MaxFragmentSize) return false; // ClientPacketFragment.cs:17-18 int payloadLength = Math.Min( header.TotalSize - MessageFragmentHeader.Size, source.Length - MessageFragmentHeader.Size); fragment = new MessageFragment( header, source.Slice(MessageFragmentHeader.Size, payloadLength).ToArray()); consumed = MessageFragmentHeader.Size + payloadLength; 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 NetworkBundle surrogate (NetworkBundle.cs:6-63) — one per /// GameMessageGroup, swapped out whole when it needs sending. private sealed class PendingBundle { private readonly Queue _messages = new(); private bool _propChanged; /// NetworkBundle.cs:11. public bool NeedsSending => _propChanged || _messages.Count > 0; /// NetworkBundle.cs:13. public bool HasMoreMessages => _messages.Count > 0; private float _clientTime = -1f; /// NetworkBundle.cs:18-27 — -1f means "no echo pending". public float ClientTime { get => _clientTime; set { _clientTime = value; _propChanged = true; } } private bool _timeSync; /// NetworkBundle.cs:29-38. public bool TimeSync { get => _timeSync; set { _timeSync = value; _propChanged = true; } } private bool _sendAck; /// NetworkBundle.cs:40-49. public bool SendAck { get => _sendAck; set { _sendAck = value; _propChanged = true; } } /// NetworkBundle.cs:51. public bool EncryptedChecksum { get; set; } public void Enqueue(byte[] message) => _messages.Enqueue(message); public byte[] Dequeue() => _messages.Dequeue(); } /// /// ACE's server-side MessageFragment (MessageFragment.cs:10-103): one /// queued GameMessage plus the split bookkeeping SendBundle drives. /// private sealed class OutboundMessage { private readonly byte[] _data; private readonly GameMessageGroup _group; private ushort _index; public uint Sequence { get; } public ushort Count { get; } public int DataRemaining { get; private set; } public bool TailSent { get; private set; } public int DataLength => _data.Length; /// MessageFragment.cs:27-36. public int NextSize => MessageFragmentHeader.Size + Math.Min(DataRemaining, MessageFragmentHeader.MaxFragmentDataSize); /// MessageFragment.cs:38. public int TailSize => MessageFragmentHeader.Size + (DataLength % MessageFragmentHeader.MaxFragmentDataSize); public OutboundMessage(byte[] data, uint sequence, GameMessageGroup group) { _data = data; _group = group; Sequence = sequence; DataRemaining = data.Length; // :47 — ceil(length / 448). Count = (ushort)Math.Ceiling( (double)data.Length / MessageFragmentHeader.MaxFragmentDataSize); _index = 0; if (Count == 1) TailSent = true; // :49-50 } /// MessageFragment.cs:54-59. public MessageFragment GetTailFragment() { var index = (ushort)(Count - 1); TailSent = true; return CreateFragment(index); } /// MessageFragment.cs:61-64. public MessageFragment GetNextFragment() => CreateFragment(_index++); /// MessageFragment.cs:66-102. private MessageFragment CreateFragment(ushort index) { if (index >= Count) throw new ArgumentOutOfRangeException(nameof(index), index, "index beyond computed count"); int position = index * MessageFragmentHeader.MaxFragmentDataSize; int dataToSend = Math.Min( DataLength - position, MessageFragmentHeader.MaxFragmentDataSize); if (DataRemaining < dataToSend) throw new InvalidOperationException("more data to send than data remaining"); byte[] payload = _data.AsSpan(position, dataToSend).ToArray(); DataRemaining -= dataToSend; return new MessageFragment( new MessageFragmentHeader { Sequence = Sequence, Id = GameMessageFragment.OutboundFragmentId, // :94 — 0x80000000 Count = Count, TotalSize = (ushort)(MessageFragmentHeader.Size + dataToSend), Index = index, Queue = (ushort)_group, }, payload); } } /// /// ACE MessageBuffer surrogate (MessageBuffer.cs:7-54). Deliberately a /// LIST keyed on nothing, exactly like ACE: TotalFragments is taken /// from the FIRST fragment seen and completion is a COUNT match, so a /// later fragment claiming a bigger Count/Index neither resizes the buffer /// nor throws. /// private sealed class PartialC2SMessage { private readonly List<(ushort Index, byte[] Payload)> _fragments = new(); private readonly int _totalFragments; public PartialC2SMessage(int totalFragments) => _totalFragments = totalFragments; /// MessageBuffer.cs:14. public bool Complete => _fragments.Count == _totalFragments; /// MessageBuffer.cs:22-31 — ignored once complete, and one /// fragment per Index. public void AddFragment(ushort index, byte[] payload) { if (Complete) return; foreach ((ushort existing, _) in _fragments) { if (existing == index) return; } _fragments.Add((index, payload)); } /// /// MessageBuffer.TryGetMessage (:36-53) — sort by Index, concatenate, /// and return NULL when the assembled stream is under the 4-byte /// ClientMessage minimum (:49-50). A null here is a dropped message /// that never advances the fragment gate. /// public byte[]? TryGetMessage() { _fragments.Sort((x, y) => x.Index - y.Index); // :38 int total = 0; foreach ((_, byte[] payload) in _fragments) total += payload.Length; if (total < 4) return null; // :49-50 byte[] message = new byte[total]; int offset = 0; foreach ((_, byte[] payload) in _fragments) { payload.CopyTo(message.AsSpan(offset)); offset += payload.Length; } return message; } } }