From e395861053ebc6ea1961e6fc6ff066b35ba8c51b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 11:42:36 +0200 Subject: [PATCH] test(net): N0 fix-up - CheckState gate, bundle coalescing, two-phase terminate Addresses the N0 review findings against commit 7e9134b4. Test-only: no production code changes. F1 (blocking) - model Session.CheckState (Session.cs:93-110). A three-value AceSessionState (AuthLoginRequest -> AuthConnectResponse -> AuthConnected) advances on SendConnectRequest (AuthenticationHandler.cs:127, :232) and on the accepted ConnectResponse (NetworkManager.cs:77). CheckState runs as the first statement of Receive after TryParse - ahead of the ConnectResponse route and ahead of VerifyCRC - so a LoginRequest out of state, a replayed ConnectResponse, or any of AckSequence|TimeSync|EchoRequest|Flow during AuthLoginRequest is dropped at zero keystream cost (ACE's PacketHeader.HasFlag is ANY-of, PacketHeader.cs:70). New StateDropCount counter. F2 - implement SendBundle faithfully (NetworkSession.cs:808-919). One NetworkBundle per GameMessageGroup (NetworkBundle.cs:6-63), swapped out and sent in ascending group order; the InvalidQueue bundle carries the ack / TimeSync / EchoResponse optional headers. As many same-bundle fragments as fit the 464-byte body budget now travel in ONE packet - one sequence, one keystream word - and a message whose remaining data fills a packet splits across packets with Count>1 fragments (:846-854, :874-888) via a port of ACE's server-side MessageFragment (MessageFragment.cs:10-103). The old "one packet per message" shortcut and its incorrect rationale are gone. F3 - model the two-phase termination. Terminate arms PendingTermination with the 2 s window (Session.cs:281-298, SessionTerminationDetails.cs:12); inbound and outbound keep running through it (Session.cs:124-133), then the pump completes the session work and releases the network resources (NetworkManager.cs:366-369 -> Session.cs:300-334 -> NetworkSession.cs:958-974). IsTerminated now means "termination armed"; IsReleased is the point of no return. F4 - port ACE's MessageBuffer exactly (MessageBuffer.cs:7-54): a List, not an index-addressed array. An assembled stream under 4 bytes returns null and is dropped WITHOUT advancing the fragment gate (:49-50 + NetworkSession.cs:504-506 removing the buffer either way), and a later fragment claiming a larger Count/Index for the same sequence completes the message instead of throwing. F5 - the C2S parse path now characterizes ACE: fragment parsing uses ACE's complete validation (16 <= Size <= 464, ClientPacketFragment.cs:12-24) with no Count==0 / Index>=Count rejection and with ReadBytes' short-read tolerance, instead of inheriting acdream's stricter production layout check. The one remaining strictness we inherit - the 1024-id cap on retransmit lists - is documented as unreachable (ACE reads into a 1024-byte buffer, so a C2S datagram can carry at most 250 ids). F6 - class doc now states that C2S CRC verification reuses acdream's own PacketHeaderOptional hashing, so the double is NOT an independent oracle on optional-header wire layout, and names the two known asymmetries (ACE has no inbound ConnectRequest parse; ACE hashes-but-does-not-advance on LoginRequest / WorldLoginRequest / ConnectResponse). F7 - hardened three weak tests: the NAK rate limit is probed at 0.9 s and at exactly 1.0 s (both closed) before 1.1 s opens it; the session timeout is probed at exactly 60 s after fixing the model's `>` to ACE's `>=` (Session.cs:140); the cache prune pins that an entry exactly 120 s old survives (:258 is strictly greater). F9 - campaign doc section 9 ledger: N0 row marked complete. Nine new tests; 687 Core.Net tests green in Release. Co-Authored-By: Claude Opus 5 --- .../2026-07-29-network-transport-campaign.md | 2 +- .../Transport/AceSessionModel.cs | 800 +++++++++++++++--- .../Transport/AceSessionModelTests.cs | 502 ++++++++++- 3 files changed, 1153 insertions(+), 151 deletions(-) diff --git a/docs/plans/2026-07-29-network-transport-campaign.md b/docs/plans/2026-07-29-network-transport-campaign.md index 3d602578..8f4b38f3 100644 --- a/docs/plans/2026-07-29-network-transport-campaign.md +++ b/docs/plans/2026-07-29-network-transport-campaign.md @@ -251,7 +251,7 @@ verbatim in each implementer prompt. | Slice | Status | Commit | Notes | |---|---|---|---| -| N0 | pending | — | | +| N0 | complete | `7e9134b4` + the `test(net): N0 fix-up` commit | ACE-behaviour double + virtual clock + lossy link; the review fix-up added the `Session.CheckState` inbound gate, faithful `SendBundle` coalescing/splitting, two-phase termination, an ACE-loose C2S fragment parse, and ACE's MessageBuffer edge cases. 687 Core.Net tests green. | | N1 | pending | — | | | N2 | pending | — | | | N3 | pending | — | | diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs index 7ed8925e..24c93b6a 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs @@ -18,15 +18,50 @@ internal enum AceTerminationReason 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. + /// TimeoutTick (NetworkSession.cs:88, Session.cs:140-144) 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 +/// 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" @@ -39,23 +74,54 @@ internal enum AceTerminationReason /// /// /// +/// 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 the WorldManager loop). +/// 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. -/// 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). +/// 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. /// /// /// @@ -64,6 +130,10 @@ 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. @@ -76,6 +146,8 @@ internal sealed class AceSessionModel 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; @@ -104,7 +176,6 @@ internal sealed class AceSessionModel private readonly Dictionary _outOfOrderFragments = new(); /// NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null). private long? _lastNakTimestamp; - private float? _pendingEchoClientTime; // ---- send state ---- /// @@ -116,7 +187,7 @@ internal sealed class AceSessionModel /// the first encrypted S2C packet sequence 2. /// private uint _packetSequence = uint.MaxValue; - /// SessionConnectionData.FragmentSequence — default 0; assigned at bundle flush (NetworkSession.cs:821). + /// 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(); @@ -124,11 +195,14 @@ internal sealed class AceSessionModel private long _nextAckTimestamp; private long? _nextResyncTimestamp; private bool _sendResync; - private bool _handshakeComplete; - private readonly List<(byte[] Body, GameMessageGroup Group)> _pendingMessages = new(); + /// 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(); @@ -154,6 +228,9 @@ internal sealed class AceSessionModel 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); @@ -162,15 +239,31 @@ internal sealed class AceSessionModel } // ---- 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; - public bool IsTerminated { get; private set; } + /// + /// 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 past which terminates the session. + /// 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). @@ -182,6 +275,8 @@ internal sealed class AceSessionModel 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 ---- @@ -202,22 +297,34 @@ internal sealed class AceSessionModel } // ===================================================================== - // Receive pipeline — NetworkSession.ProcessPacket (:269-379), in ACE's - // exact order. + // Receive pipeline — Session.ProcessPacket (Session.cs:107-113) then + // NetworkSession.ProcessPacket (:269-379), in ACE's exact order. // ===================================================================== public void Receive(ReadOnlySpan datagram) { - if (IsTerminated) + 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). + // (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); @@ -307,6 +414,47 @@ internal sealed class AceSessionModel 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) { @@ -333,7 +481,9 @@ internal sealed class AceSessionModel /// /// NetworkManager.cs:50-79 — ConnectResponse routing. The double only /// supports the exact shape retail/acdream sends (flags == - /// ConnectResponse alone, 8-byte cookie body). + /// 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) { @@ -344,10 +494,8 @@ internal sealed class AceSessionModel 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; + State = AceSessionState.AuthConnected; // NetworkManager.cs:77 _sendResync = true; // NetworkManager.cs:78 — first TimeSync goes out immediately (:47-50) TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks; ConnectResponseAccepted?.Invoke(); @@ -357,9 +505,9 @@ internal sealed class AceSessionModel private void HandleOrderedPacket(ParsedPacket packet) { // :440-443 + :650-661 — EchoRequest flags an EchoResponse onto the - // next control-bundle flush. + // InvalidQueue bundle (and forces its checksum encrypted). if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0) - _pendingEchoClientTime = packet.Optional.EchoRequestClientTime; + FlagEcho(packet.Optional.EchoRequestClientTime); // :447-448 — consume the cumulative-ack VALUE: prune the S2C cache // strictly below it. @@ -393,6 +541,14 @@ internal sealed class AceSessionModel } } + /// 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) { @@ -407,10 +563,13 @@ internal sealed class AceSessionModel _partialFragments.Add(fragment.Header.Sequence, buffer); } - buffer.Add(fragment.Header.Index, fragment.Payload); + buffer.AddFragment(fragment.Header.Index, fragment.Payload); if (buffer.Complete) { - message = buffer.Assemble(); + // :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); } } @@ -427,7 +586,9 @@ internal sealed class AceSessionModel // 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). + // 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 @@ -563,29 +724,59 @@ internal sealed class AceSessionModel } // ===================================================================== - // Send side — NetworkSession.Update (:182-249) + FlushPackets (:710-735) - // + SendPacket (:737-752), driven by the virtual clock. + // 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: 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. + /// 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 (IsTerminated) - return; + if (IsReleased) + return; // NetworkSession.cs:184-185 - // WorldManager's TimeoutTick check (NetworkSession.cs:88). Every ACE - // transport death is silence — no disconnect packet is ever sent. - if (_clock.GetTimestamp() > TimeoutDeadlineTimestamp) + // 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) @@ -593,10 +784,44 @@ internal sealed class AceSessionModel PruneCachedPackets(); } - if (_handshakeComplete) + if (State == AceSessionState.AuthConnected) { - BuildControlDraft(); - FlushMessageBundles(); + // :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 @@ -606,18 +831,26 @@ internal sealed class AceSessionModel } /// - /// Server-side game-message send. Flushed by the next - /// as its own BlobFragments|EncryptedChecksum packet (EnqueueSend sets - /// EncryptedChecksum, NetworkSession.cs:129). + /// 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) => - _pendingMessages.Add((gameMessageBody, group)); + public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group) + { + PendingBundle bundle = _bundles[(int)group]; + bundle.EncryptedChecksum = true; + bundle.Enqueue(gameMessageBody); + } /// - /// 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. + /// 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() { @@ -635,86 +868,173 @@ internal sealed class AceSessionModel PacketHeaderFlags.ConnectRequest, body, OptionalLength: body.Length)); + + State = AceSessionState.AuthConnectResponse; // AuthenticationHandler.cs:232 } /// - /// 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. + /// 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 BuildControlDraft() + private void SendBundle(PendingBundle bundle, GameMessageGroup group) { - 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; + bool writeOptionalHeaders = true; - var flags = PacketHeaderFlags.None; + // :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 (ackDue) + if (bundle.SendAck) // :925-931 { - flags |= PacketHeaderFlags.AckSequence; // :925-931 + flags |= PacketHeaderFlags.AckSequence; writer.WriteUInt32(_lastReceivedPacketSequence); - _nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks; // :215 } - if (resyncDue) + if (bundle.TimeSync) // :933-939 { - flags |= PacketHeaderFlags.TimeSync | PacketHeaderFlags.EncryptedChecksum; // :933-938 + :207 + flags |= PacketHeaderFlags.TimeSync; Span value = stackalloc byte[8]; BinaryPrimitives.WriteInt64LittleEndian( value, BitConverter.DoubleToInt64Bits(_clock.Seconds)); writer.WriteBytes(value); - _nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks; // :208 } - if (echoDue) + if (bundle.ClientTime != -1f) // :941-948 { - flags |= PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum; // :941-948 + :659 - writer.WriteFloat(_pendingEchoClientTime!.Value); - writer.WriteFloat((float)_clock.Seconds - _pendingEchoClientTime.Value); - _pendingEchoClientTime = null; + flags |= PacketHeaderFlags.EchoResponse; + writer.WriteFloat(bundle.ClientTime); + writer.WriteFloat((float)_clock.Seconds - bundle.ClientTime); } - byte[] body = writer.ToArray(); - _flushQueue.Enqueue(new OutboundDraft(flags, body, OptionalLength: body.Length)); + return writer.ToArray(); } - /// - /// 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() + /// 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) { - if (_pendingMessages.Count == 0) - return; + int total = optionalBytes.Length; + foreach (MessageFragment fragment in fragments) + total += fragment.WireSize; - foreach ((byte[] body, GameMessageGroup group) in - _pendingMessages.OrderBy(m => (int)m.Group)) // OrderBy is stable → FIFO within a group + byte[] body = new byte[total]; + optionalBytes.CopyTo(body.AsSpan()); + int offset = optionalBytes.Length; + foreach (MessageFragment fragment in fragments) { - MessageFragment fragment = GameMessageFragment.BuildSingleFragment( - _s2cFragmentSequence++, - group, - body); - byte[] fragmentBytes = GameMessageFragment.Serialize(fragment); - _flushQueue.Enqueue(new OutboundDraft( - PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, - fragmentBytes, - OptionalLength: 0)); + fragment.Header.Pack(body.AsSpan(offset)); + fragment.Payload.CopyTo(body.AsSpan(offset + MessageFragmentHeader.Size)); + offset += fragment.WireSize; } - _pendingMessages.Clear(); + return new OutboundDraft(flags, body, optionalBytes.Length); } /// FlushPackets, per packet (:710-735) + SendPacket (:737-752). @@ -812,10 +1132,9 @@ internal sealed class AceSessionModel ReadOnlySpan remaining = body.Slice(optionalLength); while (!remaining.IsEmpty) { - (MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining); - if (fragment is null) + if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed)) throw new InvalidOperationException("the model built a malformed fragment"); - hash += PacketCodec.CalculateFragmentHash32(fragment.Value); + hash += PacketCodec.CalculateFragmentHash32(fragment); remaining = remaining.Slice(consumed); } @@ -823,7 +1142,8 @@ internal sealed class AceSessionModel } /// NetworkSession.PruneCachedPackets (:251-262) — 120 s retention - /// with ACE's ushort-wrap guard expression, verbatim. + /// 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 @@ -846,10 +1166,32 @@ internal sealed class AceSessionModel _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) { - IsTerminated = true; 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) } // ===================================================================== @@ -880,11 +1222,10 @@ internal sealed class AceSessionModel ReadOnlySpan remaining = body.Slice(optionalConsumed); while (!remaining.IsEmpty) { - (MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining); - if (fragment is null) + if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed)) return false; - fragments.Add(fragment.Value); - fragmentHash += PacketCodec.CalculateFragmentHash32(fragment.Value); + fragments.Add(fragment); + fragmentHash += PacketCodec.CalculateFragmentHash32(fragment); remaining = remaining.Slice(consumed); } } @@ -893,6 +1234,47 @@ internal sealed class AceSessionModel 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 @@ -920,37 +1302,185 @@ internal sealed class AceSessionModel public uint IsaacXor; } - /// ACE MessageBuffer surrogate (NetworkSession.cs:495-518). - private sealed class PartialC2SMessage + /// ACE NetworkBundle surrogate (NetworkBundle.cs:6-63) — one per + /// GameMessageGroup, swapped out whole when it needs sending. + private sealed class PendingBundle { - private readonly byte[]?[] _parts; - private int _received; + private readonly Queue _messages = new(); + private bool _propChanged; - public PartialC2SMessage(int totalFragments) => - _parts = new byte[totalFragments][]; + /// NetworkBundle.cs:11. + public bool NeedsSending => _propChanged || _messages.Count > 0; + /// NetworkBundle.cs:13. + public bool HasMoreMessages => _messages.Count > 0; - public bool Complete => _received == _parts.Length; - - public void Add(int index, byte[] payload) + private float _clientTime = -1f; + /// NetworkBundle.cs:18-27 — -1f means "no echo pending". + public float ClientTime { - if (_parts[index] is not null) - return; // duplicate index — idempotent - _parts[index] = payload; - _received++; + get => _clientTime; + set { _clientTime = value; _propChanged = true; } } - public byte[] Assemble() + 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[]? part in _parts) - total += part!.Length; + 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[]? part in _parts) + foreach ((_, byte[] payload) in _fragments) { - byte[] bytes = part!; - bytes.CopyTo(message.AsSpan(offset)); - offset += bytes.Length; + payload.CopyTo(message.AsSpan(offset)); + offset += payload.Length; } return message; diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs index 64526ac0..72456f01 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs @@ -17,11 +17,110 @@ public sealed class AceSessionModelTests private const uint ClientId = 0x1234u; private const ulong Cookie = 0xFEEDFACECAFEBABEUL; + // ===================================================================== + // Session.CheckState — the pre-CRC inbound gate (Session.cs:93-113) + // ===================================================================== + + [Fact] + public void CheckState_DropsControlPacketsBeforeNegotiation_ThenConsumesThemAfter() + { + var clock = new VirtualClock(); + var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie); + model.LoginRequestReceived += model.SendConnectRequest; + var client = new TestAcClient(ClientSeed); + Assert.Equal(AceSessionState.AuthLoginRequest, model.State); + + // Built now, while the client's outbound wheel is at word 1 — so the + // SAME bytes must still verify after the handshake if (and only if) + // the pre-handshake delivery really cost no keystream. + byte[] cleartextAck = client.BuildCleartextAck(headerSequence: 2, ackValue: 1); + byte[] encryptedAck = client.BuildEncryptedAck(headerSequence: 2, ackValue: 1); + uint keyBeforeGate = model.Crypto.CurrentKey; + + // Session.cs:101-102 — ANY of AckSequence|TimeSync|EchoRequest|Flow + // while State == AuthLoginRequest is dropped by Session.ProcessPacket + // BEFORE NetworkSession.ProcessPacket runs, so it never reaches + // ClientPacket.VerifyCRC: no keystream word, no watermark move, no + // CRC counter. + model.Receive(cleartextAck); + model.Receive(encryptedAck); + Assert.Equal(2, model.StateDropCount); + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(0, model.DuplicateDropCount); + Assert.Equal(1u, model.LastReceivedPacketSequence); + Assert.Equal(keyBeforeGate, model.Crypto.CurrentKey); + Assert.Equal(256, model.Crypto.Headroom); + Assert.Equal(0, model.Crypto.OrphanCount); + + // Negotiate: LoginRequest → ConnectRequest (AuthenticationHandler.cs:127, + // :232) → ConnectResponse (NetworkManager.cs:77). + model.Receive(BuildLoginRequest()); + Assert.Equal(AceSessionState.AuthConnectResponse, model.State); + model.Update(); + model.TakePendingDatagrams(); + model.Receive(BuildConnectResponse()); + Assert.Equal(AceSessionState.AuthConnected, model.State); + model.Update(); + model.TakePendingDatagrams(); + + // The same cleartext ack now passes the gate. Flags are EXACTLY + // AckSequence so the watermark stays put (:474-476) and no key is + // involved. + model.Receive(cleartextAck); + Assert.Equal(2, model.StateDropCount); + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(1u, model.LastReceivedPacketSequence); + Assert.Equal(keyBeforeGate, model.Crypto.CurrentKey); + + // And the encrypted one is consumed normally: its ORIGINAL key (drawn + // before the handshake) is still the server's current key, proving the + // gate cost nothing. Its flags are not exactly AckSequence, so the + // watermark does advance. + model.Receive(encryptedAck); + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(2u, model.LastReceivedPacketSequence); + Assert.NotEqual(keyBeforeGate, model.Crypto.CurrentKey); + Assert.Equal(256, model.Crypto.Headroom); + } + + [Fact] + public void CheckState_DropsLoginRequestAndConnectResponseOutOfState() + { + (AceSessionModel model, _, _) = CreateNegotiatedModel(); + Assert.Equal(AceSessionState.AuthConnected, model.State); + + int loginRequests = 0; + int connectResponses = 0; + model.LoginRequestReceived += () => loginRequests++; + model.ConnectResponseAccepted += () => connectResponses++; + + // Session.cs:95-96 — a LoginRequest after the handshake is dropped + // before the auth handler ever sees it. + model.Receive(BuildLoginRequest()); + Assert.Equal(1, model.StateDropCount); + Assert.Equal(0, loginRequests); + + // Session.cs:98-99 (and NetworkManager.cs:60-66, whose session lookup + // requires State == AuthConnectResponse) — a replayed ConnectResponse + // cannot re-run the handshake. + model.Receive(BuildConnectResponse()); + Assert.Equal(2, model.StateDropCount); + Assert.Equal(0, connectResponses); + + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(0, model.DuplicateDropCount); + Assert.Equal(AceSessionState.AuthConnected, model.State); + } + + // ===================================================================== + // Inbound sequencing / crypto discipline + // ===================================================================== + [Fact] public void Nak_FiresOnlyAtDesiredPlusTwo_WithOneSecondRateLimit() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); - byte[][] packets = BuildSequentialPackets(client, count: 5); // seq 2..6 + byte[][] packets = BuildSequentialPackets(client, count: 7); // seq 2..8 // Gap of one: desired = 2, arrived = 3 → desired+2 (4) > 3 → buffered, NO NAK // (NetworkSession.cs:351-363 — ACE needs two arrivals past the gap). @@ -44,10 +143,23 @@ public sealed class AceSessionModelTests model.Update(); Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); - // Limiter reopens strictly after 1 s. - clock.Advance(TimeSpan.FromSeconds(1.1)); + // Just under the limit: still closed. + clock.Advance(TimeSpan.FromSeconds(0.9)); model.Receive(packets[4]); model.Update(); + Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); + + // EXACTLY 1 s: ACE's comparison is strict (`> new TimeSpan(0, 0, 1)`, + // :359), so the boundary itself is still closed. + clock.Advance(TimeSpan.FromSeconds(0.1)); + model.Receive(packets[5]); + model.Update(); + Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); + + // Limiter reopens strictly after 1 s. + clock.Advance(TimeSpan.FromSeconds(0.1)); + model.Receive(packets[6]); + model.Update(); byte[] second = Assert.Single( OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); Assert.Equal(new uint[] { 2u }, NakIds(second)); @@ -234,11 +346,181 @@ public sealed class AceSessionModelTests Assert.Equal(3u, model.LastReceivedFragmentSequence); } + // ===================================================================== + // Multi-fragment C2S reassembly — NetworkSession.ProcessFragment + // (:483-518) over ACE's MessageBuffer (MessageBuffer.cs:7-54) + // ===================================================================== + + [Fact] + public void SplitC2SMessage_StaysIncompleteUntilTheDroppedPacketIsRedelivered() + { + (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); + byte[] partA = { 0x11, 0x22, 0x33, 0x44 }; + byte[] partB = { 0x55, 0x66, 0x77, 0x88 }; + + // One logical message split across two packets (fragment sequence 1, + // Count 2), then two ordinary follow-on messages. Built in send order + // so each draws its own outbound keystream word. + byte[] head = client.BuildFragmentPacket(2, fragmentSequence: 1, count: 2, index: 0, partA); + byte[] tail = client.BuildFragmentPacket(3, fragmentSequence: 1, count: 2, index: 1, partB); + byte[] third = client.BuildGameMessagePacket(4, 2, MakeMessage(4)); + byte[] fourth = client.BuildGameMessagePacket(5, 3, MakeMessage(5)); + + model.Receive(head); + Assert.Equal(1, model.PartialFragmentBufferCount); + Assert.Empty(model.DispatchedMessages); + + // `tail` is lost. Everything behind it stacks up at the packet level + // and ACE NAKs the hole; the half-built message just sits there. + model.Receive(third); + model.Receive(fourth); + model.Update(); + byte[] nak = Assert.Single( + OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); + Assert.Equal(new uint[] { 3u }, NakIds(nak)); + Assert.Equal(1, model.PartialFragmentBufferCount); + Assert.Empty(model.DispatchedMessages); + Assert.Equal(0u, model.LastReceivedFragmentSequence); + + // Redelivery completes the message and drains everything behind it. + model.Receive(tail); + Assert.Equal(3, model.DispatchedMessages.Count); + Assert.Equal(partA.Concat(partB).ToArray(), model.DispatchedMessages[0]); + Assert.Equal(new byte[] { 4, 5 }, model.DispatchedMessages.Skip(1).Select(MessageMarker).ToArray()); + Assert.Equal(0, model.PartialFragmentBufferCount); + Assert.Equal(0, model.OutOfOrderPacketCount); + Assert.Equal(3u, model.LastReceivedFragmentSequence); + Assert.Equal(256, model.Crypto.Headroom); // the parked key was recovered + } + + [Fact] + public void SplitC2SMessage_UnderFourBytes_IsDroppedAndStallsTheFragmentGate() + { + (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); + + // Two 1-byte fragments assemble to 2 bytes — under the 4-byte + // ClientMessage minimum, so MessageBuffer.TryGetMessage returns null + // (MessageBuffer.cs:49-50). ACE removes the buffer anyway (:504-506) + // and, because `message` is null, never advances the fragment gate. + model.Receive(client.BuildFragmentPacket(2, 1, 2, 0, new byte[] { 0xAA })); + model.Receive(client.BuildFragmentPacket(3, 1, 2, 1, new byte[] { 0xBB })); + Assert.Empty(model.DispatchedMessages); + Assert.Equal(0, model.PartialFragmentBufferCount); + Assert.Equal(0u, model.LastReceivedFragmentSequence); + Assert.Equal(0, model.CrcDropCount); + + // The hole is permanent: every later message parks behind it forever + // (ACE bug-for-bug — only a fresh session recovers). + model.Receive(client.BuildGameMessagePacket(4, 2, MakeMessage(4))); + Assert.Empty(model.DispatchedMessages); + Assert.Equal(1, model.FragmentGateBufferCount); + Assert.Equal(4u, model.LastReceivedPacketSequence); + } + + [Fact] + public void SplitC2SMessage_ToleratesLaterFragmentWithLargerCountAndIndex() + { + (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); + byte[] partA = { 0x11, 0x22, 0x33, 0x44 }; + byte[] partB = { 0x55, 0x66, 0x77, 0x88 }; + + // ACE's MessageBuffer takes TotalFragments from the FIRST fragment it + // sees and completes on a COUNT match over a List (MessageBuffer.cs:9, + // :14, :22-31). A later fragment claiming Count 3 / Index 2 neither + // resizes the buffer nor lands out of range — it is simply the second + // entry, which completes the message. + model.Receive(client.BuildFragmentPacket(2, 1, count: 2, index: 0, partA)); + model.Receive(client.BuildFragmentPacket(3, 1, count: 3, index: 2, partB)); + + byte[] assembled = Assert.Single(model.DispatchedMessages); + Assert.Equal(partA.Concat(partB).ToArray(), assembled); // sorted by Index (:38) + Assert.Equal(0, model.PartialFragmentBufferCount); + Assert.Equal(1u, model.LastReceivedFragmentSequence); + Assert.Equal(0, model.CrcDropCount); + } + + [Fact] + public void ZeroCountFragment_IsAcceptedByTheParse_ThenSilentlyDropped() + { + (AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel(); + byte[] zeroCount = client.BuildFragmentPacket(2, 1, count: 0, index: 0, MakeMessage(0x77)); + + // acdream's PRODUCTION parser refuses this shape + // (MessageFragment.TryParseLayout rejects Count == 0)... + Assert.Equal( + PacketCodec.DecodeError.InvalidFragment, + PacketCodec.TryDecode(zeroCount, inboundIsaac: null).Error); + + // ...while ACE's ClientPacketFragment.Unpack (:10-23) only checks + // 16 ≤ Size ≤ 464, so the packet is parsed, CRC-verified and + // processed. ProcessFragment takes the split branch (Count != 1), the + // buffer is Complete at zero fragments, TryGetMessage returns null, + // and the whole thing evaporates — the packet still burns its + // keystream word and still advances the watermark. + model.Receive(zeroCount); + Assert.Equal(0, model.CrcDropCount); + Assert.Empty(model.DispatchedMessages); + Assert.Equal(0, model.PartialFragmentBufferCount); + Assert.Equal(0u, model.LastReceivedFragmentSequence); + Assert.Equal(2u, model.LastReceivedPacketSequence); + } + + // ===================================================================== + // Termination + timeout + // ===================================================================== + + [Fact] + public void Termination_KeepsRunningForTwoSeconds_ThenReleases() + { + (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); + model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); + Assert.Single(model.DispatchedMessages); + + // Session.Terminate (Session.cs:281-298) only ARMS PendingTermination + // with a 2 s window (SessionTerminationDetails.cs:12). + model.Receive(TransportDisconnect.Build((ushort)ClientId, iteration: 1)); + Assert.True(model.IsTerminated); + Assert.False(model.IsReleased); + Assert.Equal(AceTerminationPhase.Initialized, model.TerminationPhase); + Assert.Equal(AceTerminationReason.PacketHeaderDisconnect, model.TerminationReason); + + // Phase 1 (Session.cs:126-131): inbound still processes... + clock.Advance(TimeSpan.FromSeconds(1)); + model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); + Assert.Equal(2, model.DispatchedMessages.Count); + + // ...and Network.Update() still runs, so queued messages still leave + // ("boot messages may need sending", :129). + model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue); + model.Update(); + Assert.False(model.IsReleased); + byte[] flushed = Assert.Single(model.TakePendingDatagrams()); + Assert.Equal( + PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, + Head(flushed).Flags); + + // Past TerminationEndTicks the pump completes the session work and + // DropSession releases the network resources (:130-131, :300-334). + clock.Advance(TimeSpan.FromSeconds(1.2)); + model.Update(); + Assert.True(model.IsReleased); + Assert.Equal(AceTerminationPhase.SessionWorkCompleted, model.TerminationPhase); + model.TakePendingDatagrams(); // that pump's due cumulative ack + + // Released (NetworkSession.cs:271-272, :184-185): inbound and outbound + // are both no-ops. + model.Receive(client.BuildGameMessagePacket(MakeMessage(4))); + model.Update(); + Assert.Equal(2, model.DispatchedMessages.Count); + Assert.Empty(model.TakePendingDatagrams()); + } + [Fact] public void SixtySecondTimeout_Terminates_AndCleartextNaksDoNotRefreshIt() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // refresh → +60 s (:329-331) + long deadline = model.TimeoutDeadlineTimestamp; clock.Advance(TimeSpan.FromSeconds(59)); // A cleartext NAK is handled and RETURNS before the timeout refresh @@ -246,10 +528,14 @@ public sealed class AceSessionModelTests // initial TimeSync, so this one is served, proving the path ran.) model.Receive(client.BuildCleartextNak(2, 2u)); Assert.Equal(1, model.RetransmitsServed); + Assert.Equal(deadline, model.TimeoutDeadlineTimestamp); model.Update(); Assert.False(model.IsTerminated); - clock.Advance(TimeSpan.FromSeconds(2)); // 61 s since the last real packet + // ACE compares `DateTime.UtcNow.Ticks >= Network.TimeoutTick` + // (Session.cs:140): the boundary itself kills the session. + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(deadline, clock.GetTimestamp()); model.TakePendingDatagrams(); model.Update(); Assert.True(model.IsTerminated); @@ -285,6 +571,10 @@ public sealed class AceSessionModelTests Assert.Empty(OfExactFlags(model2.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit)); } + // ===================================================================== + // Send side — retransmit, ack, echo, cache prune, bundling + // ===================================================================== + [Fact] public void Retransmit_ServesCachedBytes_WithRetransmissionFlag_AndNoNewIsaacWord() { @@ -396,27 +686,119 @@ public sealed class AceSessionModelTests BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size + 4))); } + [Fact] + public void SendBundle_CoalescesSmallMessagesIntoOnePacket() + { + (AceSessionModel model, _, _) = CreateNegotiatedModel(); + IsaacRandom shadow = MakeIsaac(ServerSeed); + shadow.Next(); // w1 — the negotiation TimeSync + uint w2 = shadow.Next(); + uint w3 = shadow.Next(); + + // Three messages enqueued into the same bundle before one pump. + model.EnqueueGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); + model.EnqueueGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); + model.EnqueueGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); + model.Update(); + + // NetworkSession.SendBundle (:828-903) packs everything that fits into + // ONE 464-byte packet: one sequence, one keystream word, three + // fragments carrying three consecutive fragment sequences (:821). + byte[] packet = Assert.Single(model.TakePendingDatagrams()); + PacketHeader header = Head(packet); + Assert.Equal(3u, header.Sequence); + Assert.Equal( + PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, + header.Flags); + Assert.Equal(w2, ExtractIsaacKey(packet)); + + MessageFragment[] fragments = FragmentsOf(packet); + Assert.Equal(3, fragments.Length); + Assert.Equal(new uint[] { 0u, 1u, 2u }, fragments.Select(f => f.Header.Sequence).ToArray()); + Assert.All(fragments, f => Assert.Equal(1, (int)f.Header.Count)); + Assert.All(fragments, f => Assert.Equal(0, (int)f.Header.Index)); + Assert.All(fragments, f => Assert.Equal(GameMessageFragment.OutboundFragmentId, f.Header.Id)); + Assert.Equal( + new byte[] { 0xA1, 0xB2, 0xC3 }, + fragments.Select(f => f.Payload[0]).ToArray()); + + // Exactly one word was consumed by the whole bundle: the next packet + // takes the next one. + model.EnqueueGameMessage(MakeMessage(0xD4), GameMessageGroup.UIQueue); + model.Update(); + byte[] next = Assert.Single(model.TakePendingDatagrams()); + Assert.Equal(4u, Head(next).Sequence); + Assert.Equal(w3, ExtractIsaacKey(next)); + Assert.Equal(3u, Assert.Single(FragmentsOf(next)).Header.Sequence); + } + + [Fact] + public void SendBundle_SplitsLargeMessageAcrossPacketsWithCountGreaterThanOne() + { + (AceSessionModel model, _, _) = CreateNegotiatedModel(); + IsaacRandom shadow = MakeIsaac(ServerSeed); + shadow.Next(); // w1 — the negotiation TimeSync + uint w2 = shadow.Next(); + uint w3 = shadow.Next(); + + // 600 bytes > MaxFragmentDataSize (448) → Count = ceil(600/448) = 2 + // (MessageFragment.cs:47). + byte[] large = MakeLargeMessage(600); + model.EnqueueGameMessage(large, GameMessageGroup.UIQueue); + model.Update(); + + List sent = model.TakePendingDatagrams(); + Assert.Equal(2, sent.Count); + Assert.Equal(new uint[] { 3u, 4u }, sent.Select(d => Head(d).Sequence).ToArray()); + Assert.Equal(w2, ExtractIsaacKey(sent[0])); + Assert.Equal(w3, ExtractIsaacKey(sent[1])); + + // :846-854 — the head fills a packet alone; :874-880 — the tail rides + // the next one. Both carry the SAME fragment sequence and Count 2. + MessageFragment head = Assert.Single(FragmentsOf(sent[0])); + MessageFragment tail = Assert.Single(FragmentsOf(sent[1])); + Assert.Equal(2, (int)head.Header.Count); + Assert.Equal(0, (int)head.Header.Index); + Assert.Equal(MessageFragmentHeader.MaxFragmentDataSize, head.Payload.Length); + Assert.Equal(2, (int)tail.Header.Count); + Assert.Equal(1, (int)tail.Header.Index); + Assert.Equal(600 - MessageFragmentHeader.MaxFragmentDataSize, tail.Payload.Length); + Assert.Equal(head.Header.Sequence, tail.Header.Sequence); + Assert.Equal(large, head.Payload.Concat(tail.Payload).ToArray()); + } + [Fact] public void CachedPackets_PruneAfter120Seconds_ThenStaleNakGetsRejectRetransmit() { (AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel(); Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray()); // the t=0 TimeSync - // Keep the session alive across 121 s with periodic client packets - // (each refreshes the 60 s deadline) but no server pumps. + // Keep the session alive with periodic client packets (each refreshes + // the 60 s deadline). clock.Advance(TimeSpan.FromSeconds(50)); model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); clock.Advance(TimeSpan.FromSeconds(50)); model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); - clock.Advance(TimeSpan.FromSeconds(21)); - model.Receive(client.BuildGameMessagePacket(MakeMessage(4))); + model.Update(); // t = 100 s: prune runs, the seq-2 entry is well inside + Assert.Contains(2u, model.CachedPacketSequences); - model.Update(); // prune (:251-262): the seq-2 packet is 121 s old (> 120) + // The retention test is STRICTLY greater than 120 (:258), so at + // exactly 120 s the entry survives. + clock.Advance(TimeSpan.FromSeconds(20)); + model.Receive(client.BuildGameMessagePacket(MakeMessage(4))); + model.Update(); + Assert.Contains(2u, model.CachedPacketSequences); + + // The next prune cannot run until the 5 s prune interval elapses + // (:187-188, :67), so the removal probe lands at 125.1 s. + clock.Advance(TimeSpan.FromSeconds(5.1)); + model.Receive(client.BuildGameMessagePacket(MakeMessage(5))); + model.Update(); Assert.DoesNotContain(2u, model.CachedPacketSequences); // A stale NAK for the pruned id → RejectRetransmit — the §3 row // "S2C cache prunes at 120 s; old NAKs get RejectRetransmit". - model.Receive(client.BuildCleartextNak(4, 2u)); + model.Receive(client.BuildCleartextNak(6, 2u)); model.Update(); byte[] reject = Assert.Single( model.TakePendingDatagrams(), @@ -459,8 +841,9 @@ public sealed class AceSessionModelTests /// /// 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). + /// sequence to 0 and moves the state to AuthConnectResponse) → + /// ConnectResponse → the immediate first TimeSync (flushed + discarded; + /// S2C sequence 2, S2C keystream word 1, cached). /// private static (AceSessionModel Model, TestAcClient Client, VirtualClock Clock) CreateNegotiatedModel() @@ -510,6 +893,15 @@ public sealed class AceSessionModelTests private static byte[] MakeMessage(byte marker) => new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 }; + /// A message body too large for one fragment, with recognizable content. + private static byte[] MakeLargeMessage(int length) + { + byte[] body = new byte[length]; + for (int i = 0; i < length; i++) + body[i] = (byte)(i * 7 + 3); + return body; + } + private static byte MessageMarker(byte[] messageBody) => messageBody[0]; private static byte[] Markers(AceSessionModel model) => @@ -522,6 +914,28 @@ public sealed class AceSessionModelTests PacketHeaderFlags flags) => datagrams.Where(d => Head(d).Flags == flags).ToList(); + /// Every fragment carried by a datagram, in wire order. + private static MessageFragment[] FragmentsOf(byte[] datagram) + { + PacketHeader header = PacketHeader.Unpack(datagram); + ReadOnlySpan body = datagram.AsSpan(PacketHeader.Size, header.DataSize); + var optional = new PacketHeaderOptional(); + int consumed = optional.Parse(body, header.Flags); + Assert.True(consumed >= 0); + + var fragments = new List(); + ReadOnlySpan remaining = body.Slice(consumed); + while (!remaining.IsEmpty) + { + (MessageFragment? fragment, int fragmentBytes) = MessageFragment.TryParse(remaining); + Assert.NotNull(fragment); + fragments.Add(fragment!.Value); + remaining = remaining.Slice(fragmentBytes); + } + + return fragments.ToArray(); + } + private static uint[] NakIds(byte[] nakDatagram) { PacketCodec.PacketDecodeResult decoded = @@ -618,7 +1032,63 @@ public sealed class AceSessionModelTests return PacketCodec.Encode(header, fragment, _outboundIsaac); } - public byte[] BuildCleartextAck(uint headerSequence, uint ackValue) + /// + /// One packet carrying one arbitrarily-shaped fragment. Hand-rolled + /// (rather than ) because acdream's + /// production encoder refuses shapes ACE happily accepts — notably + /// Count == 0 — and the double's parse path exists to + /// characterize ACE. The checksum arithmetic mirrors + /// PacketCodec.FinalizeInPlace exactly. + /// + public byte[] BuildFragmentPacket( + uint packetSequence, + uint fragmentSequence, + ushort count, + ushort index, + byte[] payload) + { + var fragmentHeader = new MessageFragmentHeader + { + Sequence = fragmentSequence, + Id = GameMessageFragment.OutboundFragmentId, + Count = count, + TotalSize = (ushort)(MessageFragmentHeader.Size + payload.Length), + Index = index, + Queue = (ushort)GameMessageGroup.UIQueue, + }; + + int bodyLength = MessageFragmentHeader.Size + payload.Length; + byte[] datagram = new byte[PacketHeader.Size + bodyLength]; + fragmentHeader.Pack(datagram.AsSpan(PacketHeader.Size)); + payload.CopyTo(datagram.AsSpan(PacketHeader.Size + MessageFragmentHeader.Size)); + + var header = new PacketHeader + { + Sequence = packetSequence, + Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, + Id = (ushort)ClientId, + DataSize = (ushort)bodyLength, + }; + uint payloadHash = PacketCodec.CalculateFragmentHash32( + new MessageFragment(fragmentHeader, payload)); + header.Checksum = + header.CalculateHeaderHash32() + (_outboundIsaac.Next() ^ payloadHash); + header.Pack(datagram); + return datagram; + } + + public byte[] BuildCleartextAck(uint headerSequence, uint ackValue) => + BuildAck(headerSequence, ackValue, encrypted: false); + + /// + /// An ack whose flags are AckSequence|EncryptedChecksum — NOT the exact + /// AckSequence value, so it is a normal sequenced packet that consumes a + /// keystream word and advances ACE's watermark (:474-476). + /// + public byte[] BuildEncryptedAck(uint headerSequence, uint ackValue) => + BuildAck(headerSequence, ackValue, encrypted: true); + + private byte[] BuildAck(uint headerSequence, uint ackValue, bool encrypted) { byte[] body = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(body, ackValue); @@ -626,11 +1096,13 @@ public sealed class AceSessionModelTests new PacketHeader { Sequence = headerSequence, - Flags = PacketHeaderFlags.AckSequence, + Flags = encrypted + ? PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum + : PacketHeaderFlags.AckSequence, Id = (ushort)ClientId, }, body, - outboundIsaac: null); + encrypted ? _outboundIsaac : null); } public byte[] BuildCleartextNak(uint headerSequence, params uint[] ids)