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)